Ledger nano support (#624)
* networks * bump * wip * settings checkbox * add @polkadot/networks and hw-ledger * bump * wip * better error * cleanup * icon, cannot change genesis, tests for address and account * clean workflow with refresh * bump keyring * I said bump keyring * Add siging - unexpected call index * can sign * refactor * refactor useLedger * better error * first time flow in a new window * stray comments * cleanup * useless rerendering because of t :| * Apply suggestions from code review Co-authored-by: Jaco Greeff <jacogr@gmail.com> * number casting after select * Apply suggestions from code review Co-authored-by: Jaco Greeff <jacogr@gmail.com> * address comment * parenthesis Co-authored-by: Jaco Greeff <jacogr@gmail.com>
This commit is contained in:
parent
fa2a6e7636
commit
8d351e06c4
12
package.json
12
package.json
@ -8,12 +8,12 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@polkadot/api": "^3.3.1",
|
"@polkadot/api": "^3.4.2-11",
|
||||||
"@polkadot/keyring": "^5.2.3",
|
"@polkadot/keyring": "^5.2.4-6",
|
||||||
"@polkadot/networks": "^5.2.3",
|
"@polkadot/networks": "^5.2.4-6",
|
||||||
"@polkadot/types": "^3.3.1",
|
"@polkadot/types": "^3.4.2-11",
|
||||||
"@polkadot/util": "^5.2.3",
|
"@polkadot/util": "^5.2.4-6",
|
||||||
"@polkadot/util-crypto": "^5.2.3",
|
"@polkadot/util-crypto": "^5.2.4-6",
|
||||||
"babel-core": "^7.0.0-bridge.0",
|
"babel-core": "^7.0.0-bridge.0",
|
||||||
"safe-buffer": "^5.2.1",
|
"safe-buffer": "^5.2.1",
|
||||||
"typescript": "^4.1.3"
|
"typescript": "^4.1.3"
|
||||||
|
|||||||
@ -8,10 +8,10 @@
|
|||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@polkadot/api": "^3.4.1",
|
"@polkadot/api": "^3.4.2-11",
|
||||||
"@polkadot/extension-inject": "^0.36.2-6",
|
"@polkadot/extension-inject": "^0.36.2-6",
|
||||||
"@polkadot/keyring": "^5.2.3",
|
"@polkadot/keyring": "^5.2.4-6",
|
||||||
"@polkadot/phishing": "^0.3.14",
|
"@polkadot/phishing": "^0.3.14",
|
||||||
"@polkadot/ui-keyring": "^0.65.2-4"
|
"@polkadot/ui-keyring": "0.66.4-0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
|
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
|
||||||
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
|
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
|
||||||
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, ResponseAccountExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types';
|
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, ResponseAccountExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types';
|
||||||
|
|
||||||
import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults';
|
import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults';
|
||||||
import chrome from '@polkadot/extension-inject/chrome';
|
import chrome from '@polkadot/extension-inject/chrome';
|
||||||
@ -49,6 +49,12 @@ export default class Extension {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private accountsCreateHardware ({ accountIndex, address, addressOffset, genesisHash, hardwareType, name }: RequestAccountCreateHardware): boolean {
|
||||||
|
keyring.addHardware(address, hardwareType, { accountIndex, addressOffset, genesisHash, name });
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private accountsCreateSuri ({ genesisHash, name, password, suri, type }: RequestAccountCreateSuri): boolean {
|
private accountsCreateSuri ({ genesisHash, name, password, suri, type }: RequestAccountCreateSuri): boolean {
|
||||||
keyring.addUri(suri, password, { genesisHash, name }, type);
|
keyring.addUri(suri, password, { genesisHash, name }, type);
|
||||||
|
|
||||||
@ -479,6 +485,9 @@ export default class Extension {
|
|||||||
case 'pri(accounts.create.external)':
|
case 'pri(accounts.create.external)':
|
||||||
return this.accountsCreateExternal(request as RequestAccountCreateExternal);
|
return this.accountsCreateExternal(request as RequestAccountCreateExternal);
|
||||||
|
|
||||||
|
case 'pri(accounts.create.hardware)':
|
||||||
|
return this.accountsCreateHardware(request as RequestAccountCreateHardware);
|
||||||
|
|
||||||
case 'pri(accounts.create.suri)':
|
case 'pri(accounts.create.suri)':
|
||||||
return this.accountsCreateSuri(request as RequestAccountCreateSuri);
|
return this.accountsCreateSuri(request as RequestAccountCreateSuri);
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export interface AccountJson extends KeyringPair$Meta {
|
|||||||
address: string;
|
address: string;
|
||||||
genesisHash?: string | null;
|
genesisHash?: string | null;
|
||||||
isExternal?: boolean;
|
isExternal?: boolean;
|
||||||
|
isHardware?: boolean;
|
||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
parentAddress?: string;
|
parentAddress?: string;
|
||||||
@ -73,6 +74,7 @@ export interface SigningRequest {
|
|||||||
export interface RequestSignatures {
|
export interface RequestSignatures {
|
||||||
// private/internal requests, i.e. from a popup
|
// private/internal requests, i.e. from a popup
|
||||||
'pri(accounts.create.external)': [RequestAccountCreateExternal, boolean];
|
'pri(accounts.create.external)': [RequestAccountCreateExternal, boolean];
|
||||||
|
'pri(accounts.create.hardware)': [RequestAccountCreateHardware, boolean];
|
||||||
'pri(accounts.create.suri)': [RequestAccountCreateSuri, boolean];
|
'pri(accounts.create.suri)': [RequestAccountCreateSuri, boolean];
|
||||||
'pri(accounts.edit)': [RequestAccountEdit, boolean];
|
'pri(accounts.edit)': [RequestAccountEdit, boolean];
|
||||||
'pri(accounts.export)': [RequestAccountExport, ResponseAccountExport];
|
'pri(accounts.export)': [RequestAccountExport, ResponseAccountExport];
|
||||||
@ -176,6 +178,15 @@ export interface RequestAccountCreateSuri {
|
|||||||
type?: KeypairType;
|
type?: KeypairType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RequestAccountCreateHardware {
|
||||||
|
accountIndex: number;
|
||||||
|
address: string;
|
||||||
|
addressOffset: number;
|
||||||
|
genesisHash: string;
|
||||||
|
hardwareType: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RequestAccountChangePassword {
|
export interface RequestAccountChangePassword {
|
||||||
address: string;
|
address: string;
|
||||||
oldPass: string;
|
oldPass: string;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Copyright 2019-2021 @polkadot/extension-base authors & contributors
|
// Copyright 2019-2021 @polkadot/extension-base authors & contributors
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
const ALLOWED_PATH = ['/', '/account/restore-json'] as const;
|
const ALLOWED_PATH = ['/', '/account/import-ledger', '/account/restore-json'] as const;
|
||||||
const PORT_CONTENT = 'content';
|
const PORT_CONTENT = 'content';
|
||||||
const PHISHING_PAGE_REDIRECT = '/phishing-page-detected';
|
const PHISHING_PAGE_REDIRECT = '/phishing-page-detected';
|
||||||
const PORT_EXTENSION = 'extension';
|
const PORT_EXTENSION = 'extension';
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@polkadot/networks": "^5.2.3"
|
"@polkadot/networks": "^5.2.4-6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@polkadot/api": "*"
|
"@polkadot/api": "*"
|
||||||
|
|||||||
@ -9,8 +9,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@polkadot/extension-inject": "^0.36.2-6",
|
"@polkadot/extension-inject": "^0.36.2-6",
|
||||||
"@polkadot/util": "^5.2.3",
|
"@polkadot/util": "^5.2.4-6",
|
||||||
"@polkadot/util-crypto": "^5.2.3"
|
"@polkadot/util-crypto": "^5.2.4-6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@polkadot/api": "*"
|
"@polkadot/api": "*"
|
||||||
|
|||||||
@ -7,16 +7,19 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.34",
|
"@fortawesome/fontawesome-svg-core": "^1.2.34",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^5.15.2",
|
||||||
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||||
"@polkadot/extension-base": "^0.36.2-6",
|
"@polkadot/extension-base": "^0.36.2-6",
|
||||||
"@polkadot/extension-inject": "^0.36.2-6",
|
"@polkadot/extension-inject": "^0.36.2-6",
|
||||||
"@polkadot/keyring": "^5.2.3",
|
"@polkadot/hw-ledger": "^5.2.4-6",
|
||||||
"@polkadot/react-identicon": "^0.65.2-4",
|
"@polkadot/keyring": "^5.2.4-6",
|
||||||
"@polkadot/react-qr": "^0.65.2-4",
|
"@polkadot/networks": "^5.2.4-6",
|
||||||
"@polkadot/util": "^5.2.3",
|
"@polkadot/react-identicon": "^0.62.2-18",
|
||||||
"@polkadot/util-crypto": "^5.2.3",
|
"@polkadot/react-qr": "^0.62.2-18",
|
||||||
|
"@polkadot/util": "^5.2.4-6",
|
||||||
|
"@polkadot/util-crypto": "^5.2.4-6",
|
||||||
"i18next": "^19.8.4",
|
"i18next": "^19.8.4",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
|
|||||||
@ -38,6 +38,7 @@ describe('Account component', () => {
|
|||||||
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account');
|
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account');
|
||||||
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account');
|
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account');
|
||||||
expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account');
|
expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account');
|
||||||
|
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show Export option if account is external', () => {
|
it('does not show Export option if account is external', () => {
|
||||||
@ -47,6 +48,7 @@ describe('Account component', () => {
|
|||||||
expect(wrapper.find('a.menuItem').length).toBe(2);
|
expect(wrapper.find('a.menuItem').length).toBe(2);
|
||||||
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
||||||
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Forget Account');
|
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Forget Account');
|
||||||
|
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show Derive option if account is of ethereum type', () => {
|
it('does not show Derive option if account is of ethereum type', () => {
|
||||||
@ -57,5 +59,16 @@ describe('Account component', () => {
|
|||||||
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
||||||
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Export Account');
|
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Export Account');
|
||||||
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Forget Account');
|
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Forget Account');
|
||||||
|
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show genesis hash selection dropsown if account is hardware', () => {
|
||||||
|
wrapper = mountAccountComponent({ isExternal: true, isHardware: true });
|
||||||
|
wrapper.find('.settings').first().simulate('click');
|
||||||
|
|
||||||
|
expect(wrapper.find('a.menuItem').length).toBe(2);
|
||||||
|
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
||||||
|
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Forget Account');
|
||||||
|
expect(wrapper.find('.genesisSelection').exists()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,7 +24,7 @@ interface EditState {
|
|||||||
toggleActions: number;
|
toggleActions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Account ({ address, className, genesisHash, isExternal, isHidden, parentName, suri, type }: Props): React.ReactElement<Props> {
|
function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, parentName, suri, type }: Props): React.ReactElement<Props> {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const onAction = useContext(ActionContext);
|
const onAction = useContext(ActionContext);
|
||||||
const [{ isEditing, toggleActions }, setEditing] = useState<EditState>({ isEditing: false, toggleActions: 0 });
|
const [{ isEditing, toggleActions }, setEditing] = useState<EditState>({ isEditing: false, toggleActions: 0 });
|
||||||
@ -88,18 +88,22 @@ function Account ({ address, className, genesisHash, isExternal, isHidden, paren
|
|||||||
>
|
>
|
||||||
{t<string>('Forget Account')}
|
{t<string>('Forget Account')}
|
||||||
</Link>
|
</Link>
|
||||||
<MenuDivider />
|
{!isHardware && (
|
||||||
<div className='menuItem'>
|
<>
|
||||||
<Dropdown
|
<MenuDivider />
|
||||||
className='inputItem'
|
<div className='menuItem'>
|
||||||
label=''
|
<Dropdown
|
||||||
onChange={_onChangeGenesis}
|
className='genesisSelection'
|
||||||
options={genesisOptions}
|
label=''
|
||||||
value={genesisHash || ''}
|
onChange={_onChangeGenesis}
|
||||||
/>
|
options={genesisOptions}
|
||||||
</div>
|
value={genesisHash || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
), [_onChangeGenesis, _toggleEdit, address, genesisHash, isExternal, t, type]);
|
), [_onChangeGenesis, _toggleEdit, address, genesisHash, isExternal, isHardware, t, type]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
@ -152,7 +156,7 @@ export default styled(Account)`
|
|||||||
min-width: 13rem;
|
min-width: 13rem;
|
||||||
padding: 4px 16px;
|
padding: 4px 16px;
|
||||||
|
|
||||||
.inputItem {
|
.genesisSelection {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ function AddAccount ({ className }: Props): React.ReactElement<Props> {
|
|||||||
<Header
|
<Header
|
||||||
showAdd
|
showAdd
|
||||||
showSettings
|
showSettings
|
||||||
text='Add Account'
|
text={t<string>('Add Account')}
|
||||||
/>
|
/>
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div className='image'>
|
<div className='image'>
|
||||||
|
|||||||
178
packages/extension-ui/src/Popup/ImportLedger.tsx
Normal file
178
packages/extension-ui/src/Popup/ImportLedger.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { faSync } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import settings from '@polkadot/ui-settings';
|
||||||
|
|
||||||
|
import { ActionContext, Address, Button, ButtonArea, Dropdown, VerticalSpace, Warning } from '../components';
|
||||||
|
import { useLedger } from '../hooks/useLedger';
|
||||||
|
import useTranslation from '../hooks/useTranslation';
|
||||||
|
import { createAccountHardware } from '../messaging';
|
||||||
|
import { Header } from '../partials';
|
||||||
|
import { ThemeProps } from '../types';
|
||||||
|
import ledgerChains from '../util/legerChains';
|
||||||
|
|
||||||
|
interface AccOption {
|
||||||
|
text: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkOption {
|
||||||
|
text: string;
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAIL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
||||||
|
|
||||||
|
interface Props extends ThemeProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportLedger ({ className }: Props): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [accountIndex, setAccountIndex] = useState<number>(0);
|
||||||
|
const [addressOffset, setAddressOffset] = useState<number>(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [genesis, setGenesis] = useState<string | null>(null);
|
||||||
|
const onAction = useContext(ActionContext);
|
||||||
|
const name = useMemo(() => `ledger ${accountIndex}/${addressOffset}`, [accountIndex, addressOffset]);
|
||||||
|
const { address, error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, refresh, warning: ledgerWarning } = useLedger(genesis, accountIndex, addressOffset);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (address) {
|
||||||
|
settings.set({ ledgerConn: 'webusb' });
|
||||||
|
}
|
||||||
|
}, [address]);
|
||||||
|
|
||||||
|
const accOps = useRef(AVAIL.map((value): AccOption => ({
|
||||||
|
text: t('Account type {{index}}', { replace: { index: value } }),
|
||||||
|
value
|
||||||
|
})));
|
||||||
|
|
||||||
|
const addOps = useRef(AVAIL.map((value): AccOption => ({
|
||||||
|
text: t('Address index {{index}}', { replace: { index: value } }),
|
||||||
|
value
|
||||||
|
})));
|
||||||
|
|
||||||
|
const networkOps = useRef(
|
||||||
|
[{
|
||||||
|
text: 'Select network',
|
||||||
|
value: ''
|
||||||
|
},
|
||||||
|
...ledgerChains.map(({ displayName, genesisHash }): NetworkOption => ({
|
||||||
|
text: displayName,
|
||||||
|
value: genesisHash[0]
|
||||||
|
}))]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onSave = useCallback(
|
||||||
|
() => {
|
||||||
|
if (address && genesis) {
|
||||||
|
setIsBusy(true);
|
||||||
|
|
||||||
|
createAccountHardware(address, 'ledger', accountIndex, addressOffset, name, genesis)
|
||||||
|
.then(() => onAction('/'))
|
||||||
|
.catch((error: Error) => {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
setIsBusy(false);
|
||||||
|
setError(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[accountIndex, address, addressOffset, genesis, name, onAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
// select element is returning a string
|
||||||
|
const _onSetAccountIndex = useCallback((value: number) => setAccountIndex(Number(value)), []);
|
||||||
|
const _onSetAddressOffset = useCallback((value: number) => setAddressOffset(Number(value)), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
showBackArrow
|
||||||
|
text={t<string>('Import Ledger Account')}
|
||||||
|
/>
|
||||||
|
<div className={className}>
|
||||||
|
<Address
|
||||||
|
address={address}
|
||||||
|
genesisHash={genesis}
|
||||||
|
isExternal
|
||||||
|
isHardware
|
||||||
|
name={address ? name : ''}
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
className='network'
|
||||||
|
label={t<string>('Network')}
|
||||||
|
onChange={setGenesis}
|
||||||
|
options={networkOps.current}
|
||||||
|
value={genesis}
|
||||||
|
/>
|
||||||
|
{genesis && address && !ledgerError && (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
className='accountType'
|
||||||
|
label={t<string>('account type')}
|
||||||
|
onChange={_onSetAccountIndex}
|
||||||
|
options={accOps.current}
|
||||||
|
value={accountIndex}
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
className='accountIndex'
|
||||||
|
label={t<string>('address index')}
|
||||||
|
onChange={_onSetAddressOffset}
|
||||||
|
options={addOps.current}
|
||||||
|
value={addressOffset}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!!ledgerWarning && (
|
||||||
|
<Warning>
|
||||||
|
{ledgerWarning}
|
||||||
|
</Warning>
|
||||||
|
)}
|
||||||
|
{(!!error || !!ledgerError) && (
|
||||||
|
<Warning
|
||||||
|
isDanger
|
||||||
|
>
|
||||||
|
{error || ledgerError}
|
||||||
|
</Warning>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<VerticalSpace/>
|
||||||
|
<ButtonArea>
|
||||||
|
{ledgerLocked
|
||||||
|
? (
|
||||||
|
<Button
|
||||||
|
isBusy={ledgerLoading || isBusy}
|
||||||
|
onClick={refresh}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSync} />
|
||||||
|
{t<string>('Refresh')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Button
|
||||||
|
isBusy={ledgerLoading || isBusy}
|
||||||
|
isDisabled={!!error || !!ledgerError || !address || !genesis}
|
||||||
|
onClick={_onSave}
|
||||||
|
>
|
||||||
|
{t<string>('Import Account')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</ButtonArea>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default styled(ImportLedger)`
|
||||||
|
.refreshIcon {
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
104
packages/extension-ui/src/Popup/Signing/LedgerSign.tsx
Normal file
104
packages/extension-ui/src/Popup/Signing/LedgerSign.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import type { ExtrinsicPayload } from '@polkadot/types/interfaces';
|
||||||
|
|
||||||
|
import { faSync } 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 { Button, Warning } from '../../components';
|
||||||
|
import { useLedger } from '../../hooks/useLedger';
|
||||||
|
import useTranslation from '../../hooks/useTranslation';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accountIndex?: number;
|
||||||
|
addressOffset? : number;
|
||||||
|
className?: string;
|
||||||
|
error: string | null;
|
||||||
|
genesisHash?: string;
|
||||||
|
onSignature?: ({ signature }: { signature: string }) => void;
|
||||||
|
payload?: ExtrinsicPayload;
|
||||||
|
setError: (value: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, onSignature, payload, setError } : Props): React.ReactElement<Props> {
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, ledger, refresh, warning: ledgerWarning } = useLedger(genesisHash, accountIndex, addressOffset);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ledgerError) {
|
||||||
|
setError(ledgerError);
|
||||||
|
}
|
||||||
|
}, [ledgerError, setError]);
|
||||||
|
|
||||||
|
const _onRefresh = useCallback(() => {
|
||||||
|
refresh();
|
||||||
|
setError(null);
|
||||||
|
}, [refresh, setError]);
|
||||||
|
|
||||||
|
const _onSignLedger = useCallback(
|
||||||
|
(): void => {
|
||||||
|
if (!ledger || !payload || !onSignature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setIsBusy(true);
|
||||||
|
ledger.sign(payload.toU8a(true), accountIndex, addressOffset)
|
||||||
|
.then((signature) => {
|
||||||
|
onSignature(signature);
|
||||||
|
}).catch((e: Error) => {
|
||||||
|
setError(e.message);
|
||||||
|
setIsBusy(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[accountIndex, addressOffset, ledger, onSignature, payload, setError]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{!!ledgerWarning && (
|
||||||
|
<Warning>
|
||||||
|
{ledgerWarning}
|
||||||
|
</Warning>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Warning isDanger>
|
||||||
|
{error}
|
||||||
|
</Warning>
|
||||||
|
)}
|
||||||
|
{ledgerLocked
|
||||||
|
? (
|
||||||
|
<Button
|
||||||
|
isBusy={isBusy || ledgerLoading}
|
||||||
|
onClick={_onRefresh}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSync} />
|
||||||
|
{t<string>('Refresh')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Button
|
||||||
|
isBusy={isBusy || ledgerLoading}
|
||||||
|
onClick={_onSignLedger}
|
||||||
|
>
|
||||||
|
{t<string>('Sign on Ledger')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default styled(LedgerSign)`
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 6px 24px;
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -13,6 +13,7 @@ import { ActionContext, Address, VerticalSpace } from '../../../components';
|
|||||||
import { approveSignSignature } from '../../../messaging';
|
import { approveSignSignature } from '../../../messaging';
|
||||||
import Bytes from '../Bytes';
|
import Bytes from '../Bytes';
|
||||||
import Extrinsic from '../Extrinsic';
|
import Extrinsic from '../Extrinsic';
|
||||||
|
import LedgerSign from '../LedgerSign';
|
||||||
import Qr from '../Qr';
|
import Qr from '../Qr';
|
||||||
import SignArea from './SignArea';
|
import SignArea from './SignArea';
|
||||||
|
|
||||||
@ -37,7 +38,7 @@ function isRawPayload (payload: SignerPayloadJSON | SignerPayloadRaw): payload i
|
|||||||
return !!(payload as SignerPayloadRaw).data;
|
return !!(payload as SignerPayloadRaw).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Request ({ account: { isExternal }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement<Props> | null {
|
export default function Request ({ account: { accountIndex, addressOffset, isExternal, isHardware }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement<Props> | null {
|
||||||
const onAction = useContext(ActionContext);
|
const onAction = useContext(ActionContext);
|
||||||
const [{ hexBytes, payload }, setData] = useState<Data>({ hexBytes: null, payload: null });
|
const [{ hexBytes, payload }, setData] = useState<Data>({ hexBytes: null, payload: null });
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -81,9 +82,10 @@ export default function Request ({ account: { isExternal }, buttonText, isFirst,
|
|||||||
address={json.address}
|
address={json.address}
|
||||||
genesisHash={json.genesisHash}
|
genesisHash={json.genesisHash}
|
||||||
isExternal={isExternal}
|
isExternal={isExternal}
|
||||||
|
isHardware={isHardware}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isExternal
|
{isExternal && !isHardware
|
||||||
? (
|
? (
|
||||||
<Qr
|
<Qr
|
||||||
onSignature={_onSignature}
|
onSignature={_onSignature}
|
||||||
@ -99,6 +101,17 @@ export default function Request ({ account: { isExternal }, buttonText, isFirst,
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
{isHardware && (
|
||||||
|
<LedgerSign
|
||||||
|
accountIndex={accountIndex as number || 0}
|
||||||
|
addressOffset={addressOffset as number || 0}
|
||||||
|
error={error}
|
||||||
|
genesisHash={json.genesisHash}
|
||||||
|
onSignature={_onSignature}
|
||||||
|
payload={payload}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SignArea
|
<SignArea
|
||||||
buttonText={buttonText}
|
buttonText={buttonText}
|
||||||
error={error}
|
error={error}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import CreateAccount from './CreateAccount';
|
|||||||
import Derive from './Derive';
|
import Derive from './Derive';
|
||||||
import Export from './Export';
|
import Export from './Export';
|
||||||
import Forget from './Forget';
|
import Forget from './Forget';
|
||||||
|
import ImportLedger from './ImportLedger';
|
||||||
import ImportQr from './ImportQr';
|
import ImportQr from './ImportQr';
|
||||||
import ImportSeed from './ImportSeed';
|
import ImportSeed from './ImportSeed';
|
||||||
import Metadata from './Metadata';
|
import Metadata from './Metadata';
|
||||||
@ -135,6 +136,7 @@ export default function Popup (): React.ReactElement {
|
|||||||
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
||||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||||
|
<Route path='/account/import-ledger'>{wrapWithErrorBoundary(<ImportLedger />, 'import-ledger')}</Route>
|
||||||
<Route path='/account/import-qr'>{wrapWithErrorBoundary(<ImportQr />, 'import-qr')}</Route>
|
<Route path='/account/import-qr'>{wrapWithErrorBoundary(<ImportQr />, 'import-qr')}</Route>
|
||||||
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
|
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
|
||||||
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
|
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
|
||||||
|
|||||||
@ -36,13 +36,24 @@ interface AccountTestGenesisJson extends AccountTestJson {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const externalAccount = { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', expectedIconTheme: 'polkadot', isExternal: true, name: 'External Account', type: 'sr25519' } as AccountJson;
|
const externalAccount = { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', expectedIconTheme: 'polkadot', isExternal: true, name: 'External Account', type: 'sr25519' } as AccountJson;
|
||||||
|
const hardwareAccount = {
|
||||||
|
address: 'HDE6uFdw53SwUyfKSsjwZNmS2sziWMPuY6uJhGHcFzLYRaJ',
|
||||||
|
expectedIconTheme: 'polkadot',
|
||||||
|
// Kusama genesis hash
|
||||||
|
genesisHash: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe',
|
||||||
|
isExternal: true,
|
||||||
|
isHardware: true,
|
||||||
|
name: 'Hardware Account',
|
||||||
|
type: 'sr25519'
|
||||||
|
} as AccountJson;
|
||||||
|
|
||||||
const accounts = [
|
const accounts = [
|
||||||
{ address: '5HSDXAC3qEMkSzZK377sTD1zJhjaPiX5tNWppHx2RQMYkjaJ', expectedIconTheme: 'polkadot', name: 'ECDSA Account', type: 'ecdsa' },
|
{ address: '5HSDXAC3qEMkSzZK377sTD1zJhjaPiX5tNWppHx2RQMYkjaJ', expectedIconTheme: 'polkadot', name: 'ECDSA Account', type: 'ecdsa' },
|
||||||
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', expectedIconTheme: 'polkadot', name: 'Ed Account', type: 'ed25519' },
|
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', expectedIconTheme: 'polkadot', name: 'Ed Account', type: 'ed25519' },
|
||||||
{ address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', expectedIconTheme: 'polkadot', name: 'Parent Sr Account', type: 'sr25519' },
|
{ address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', expectedIconTheme: 'polkadot', name: 'Parent Sr Account', type: 'sr25519' },
|
||||||
{ address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', expectedIconTheme: 'ethereum', name: 'Ethereum', type: 'ethereum' },
|
{ address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', expectedIconTheme: 'ethereum', name: 'Ethereum', type: 'ethereum' },
|
||||||
{ ...externalAccount }
|
{ ...externalAccount },
|
||||||
|
{ ...hardwareAccount }
|
||||||
] as AccountTestJson[];
|
] as AccountTestJson[];
|
||||||
|
|
||||||
// With Westend genesis Hash
|
// With Westend genesis Hash
|
||||||
@ -259,6 +270,18 @@ describe('Address', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Hardware wallet account', () => {
|
||||||
|
let wrapper: ReactWrapper;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
wrapper = await getWrapper(hardwareAccount, [], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a usb icon in front of its name', () => {
|
||||||
|
expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="usb"]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Encoding and label based on Metadata', () => {
|
describe('Encoding and label based on Metadata', () => {
|
||||||
let wrapper: ReactWrapper;
|
let wrapper: ReactWrapper;
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type { SettingsStruct } from '@polkadot/ui-settings/types';
|
|||||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||||
import type { ThemeProps } from '../types';
|
import type { ThemeProps } from '../types';
|
||||||
|
|
||||||
|
import { faUsb } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { faCopy, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
import { faCopy, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
@ -38,6 +39,7 @@ export interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
genesisHash?: string | null;
|
genesisHash?: string | null;
|
||||||
isExternal?: boolean | null;
|
isExternal?: boolean | null;
|
||||||
|
isHardware?: boolean | null;
|
||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
parentName?: string | null;
|
parentName?: string | null;
|
||||||
@ -92,7 +94,7 @@ function recodeAddress (address: string, accounts: AccountWithChildren[], chain:
|
|||||||
const ACCOUNTS_SCREEN_HEIGHT = 550;
|
const ACCOUNTS_SCREEN_HEIGHT = 550;
|
||||||
const defaultRecoded = { account: null, formatted: null, prefix: 42, type: DEFAULT_TYPE };
|
const defaultRecoded = { account: null, formatted: null, prefix: 42, type: DEFAULT_TYPE };
|
||||||
|
|
||||||
function Address ({ actions, address, children, className, genesisHash, isExternal, isHidden, name, parentName, suri, toggleActions, type: givenType }: Props): React.ReactElement<Props> {
|
function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, suri, toggleActions, type: givenType }: Props): React.ReactElement<Props> {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { accounts } = useContext(AccountContext);
|
const { accounts } = useContext(AccountContext);
|
||||||
const settings = useContext(SettingsContext);
|
const settings = useContext(SettingsContext);
|
||||||
@ -155,16 +157,24 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
|
|||||||
);
|
);
|
||||||
|
|
||||||
const Name = () => {
|
const Name = () => {
|
||||||
const displayName = name || account?.name || t('<unknown>');
|
const accountName = name || account?.name;
|
||||||
|
const displayName = accountName || t('<unknown>');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(account?.isExternal || isExternal) && (
|
{!!accountName && (account?.isExternal || isExternal) && (
|
||||||
<FontAwesomeIcon
|
(account?.isHardware || isHardware)
|
||||||
className='externalIcon'
|
? <FontAwesomeIcon
|
||||||
icon={faExternalLinkSquareAlt}
|
className='hardwareIcon'
|
||||||
title={t('external account')}
|
icon={faUsb}
|
||||||
/>
|
rotation={270}
|
||||||
|
title={t('hardware wallet account')}
|
||||||
|
/>
|
||||||
|
: <FontAwesomeIcon
|
||||||
|
className='externalIcon'
|
||||||
|
icon={faExternalLinkSquareAlt}
|
||||||
|
title={t('external account')}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span title={displayName}>{displayName}</span>
|
<span title={displayName}>{displayName}</span>
|
||||||
</>);
|
</>);
|
||||||
@ -335,9 +345,10 @@ export default styled(Address)(({ theme }: ThemeProps) => `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.externalIcon {
|
.externalIcon, .hardwareIcon {
|
||||||
margin-right: 0.3rem;
|
margin-right: 0.3rem;
|
||||||
color: ${theme.labelColor}
|
color: ${theme.labelColor};
|
||||||
|
width: 0.875em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.identityIcon {
|
.identityIcon {
|
||||||
|
|||||||
@ -83,6 +83,10 @@ export default styled(Button)(({ isDanger, theme }: Props) => `
|
|||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
&.isBusy {
|
&.isBusy {
|
||||||
background: rgba(96,96,96,0.15);
|
background: rgba(96,96,96,0.15);
|
||||||
|
|
||||||
|
|||||||
@ -13,13 +13,18 @@ interface Props {
|
|||||||
isDanger?: boolean;
|
isDanger?: boolean;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
title?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Link ({ children, className = '', isDisabled, onClick, to }: Props): React.ReactElement<Props> {
|
function Link ({ children, className = '', isDisabled, onClick, title, to }: Props): React.ReactElement<Props> {
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
return (
|
return (
|
||||||
<div className={`${className} isDisabled`}>{children}</div>
|
<div
|
||||||
|
className={`${className} isDisabled`}
|
||||||
|
title={title}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,6 +33,7 @@ function Link ({ children, className = '', isDisabled, onClick, to }: Props): Re
|
|||||||
<RouterLink
|
<RouterLink
|
||||||
className={className}
|
className={className}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
to={to}
|
to={to}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -38,6 +44,7 @@ function Link ({ children, className = '', isDisabled, onClick, to }: Props): Re
|
|||||||
className={className}
|
className={className}
|
||||||
href='#'
|
href='#'
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export default styled(Menu)(({ theme }: ThemeProps) => `
|
|||||||
border: 1px solid ${theme.boxBorderColor};
|
border: 1px solid ${theme.boxBorderColor};
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
box-shadow: 0 0 10px ${theme.boxShadow};
|
box-shadow: 0 0 10px ${theme.boxShadow};
|
||||||
margin-top: 90px;
|
margin-top: 60px;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
134
packages/extension-ui/src/hooks/useLedger.ts
Normal file
134
packages/extension-ui/src/hooks/useLedger.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Ledger } from '@polkadot/hw-ledger';
|
||||||
|
import { Network } from '@polkadot/networks/types';
|
||||||
|
import uiSettings from '@polkadot/ui-settings';
|
||||||
|
import { assert } from '@polkadot/util';
|
||||||
|
|
||||||
|
import ledgerChains from '../util/legerChains';
|
||||||
|
import useTranslation from './useTranslation';
|
||||||
|
|
||||||
|
interface StateBase {
|
||||||
|
isLedgerCapable: boolean;
|
||||||
|
isLedgerEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State extends StateBase {
|
||||||
|
address: string | null;
|
||||||
|
error: string | null;
|
||||||
|
isLedgerCapable: boolean;
|
||||||
|
isLedgerEnabled: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
isLocked: boolean;
|
||||||
|
ledger: Ledger | null;
|
||||||
|
refresh: () => void;
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNetwork (genesis: string): Network | undefined {
|
||||||
|
return ledgerChains.find(({ genesisHash }) => genesisHash[0] === genesis);
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrieveLedger (genesis: string): Ledger {
|
||||||
|
let ledger: Ledger | null = null;
|
||||||
|
|
||||||
|
const { isLedgerCapable } = getState();
|
||||||
|
|
||||||
|
assert(isLedgerCapable, 'Incompatible browser, only Chrome is supported');
|
||||||
|
|
||||||
|
const def = getNetwork(genesis);
|
||||||
|
|
||||||
|
assert(def, `Unable to find supported chain for ${genesis}`);
|
||||||
|
|
||||||
|
ledger = new Ledger('webusb', def.network);
|
||||||
|
|
||||||
|
return ledger;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState (): StateBase {
|
||||||
|
const isLedgerCapable = !!(window as unknown as { USB?: unknown }).USB;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLedgerCapable,
|
||||||
|
isLedgerEnabled: isLedgerCapable && uiSettings.ledgerConn !== 'none'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLedger (genesis?: string | null, accountIndex = 0, addressOffset = 0): State {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
|
const [refreshLock, setRefreshLock] = useState(false);
|
||||||
|
const [warning, setWarning] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [address, setAddress] = useState<string | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ledger = useMemo(() => {
|
||||||
|
setIsLocked(false);
|
||||||
|
setRefreshLock(false);
|
||||||
|
|
||||||
|
// this trick allows to refresh the ledger on demand
|
||||||
|
// when it is shown as locked and the user has actually
|
||||||
|
// unlocked it, which we can't know.
|
||||||
|
if (refreshLock || genesis) {
|
||||||
|
if (!genesis) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return retrieveLedger(genesis);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [genesis, refreshLock]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ledger || !genesis) {
|
||||||
|
setAddress(null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setWarning(null);
|
||||||
|
|
||||||
|
ledger.getAddress(false, accountIndex, addressOffset)
|
||||||
|
.then((res) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setAddress(res.address);
|
||||||
|
}).catch((e: Error) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
const { network } = getNetwork(genesis) || { network: 'unknown network' };
|
||||||
|
|
||||||
|
const warningMessage = e.message.includes('Code: 26628')
|
||||||
|
? t<string>('Is your ledger locked?')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const errorMessage = e.message.includes('App does not seem to be open')
|
||||||
|
? t<string>('App "{{network}}" does not seem to be open', { replace: { network } })
|
||||||
|
: e.message;
|
||||||
|
|
||||||
|
setIsLocked(true);
|
||||||
|
setWarning(warningMessage);
|
||||||
|
setError(t<string>(
|
||||||
|
'Ledger error: {{errorMessage}}',
|
||||||
|
{ replace: { errorMessage } }
|
||||||
|
));
|
||||||
|
console.error(e);
|
||||||
|
setAddress(null);
|
||||||
|
});
|
||||||
|
// If the dependency array is exhaustive, with t, the translation function, it
|
||||||
|
// triggers a useless re-render when ledger device is connected.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [accountIndex, addressOffset, genesis, ledger]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setRefreshLock(true);
|
||||||
|
setError(null);
|
||||||
|
setWarning(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return ({ ...getState(), address, error, isLoading, isLocked, ledger, refresh, warning });
|
||||||
|
}
|
||||||
@ -117,6 +117,10 @@ export async function createAccountExternal (name: string, address: string, gene
|
|||||||
return sendMessage('pri(accounts.create.external)', { address, genesisHash, name });
|
return sendMessage('pri(accounts.create.external)', { address, genesisHash, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createAccountHardware (address: string, hardwareType: string, accountIndex: number, addressOffset: number, name: string, genesisHash: string): Promise<boolean> {
|
||||||
|
return sendMessage('pri(accounts.create.hardware)', { accountIndex, address, addressOffset, genesisHash, hardwareType, name });
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAccountSuri (name: string, password: string, suri: string, type?: KeypairType): Promise<boolean> {
|
export async function createAccountSuri (name: string, password: string, suri: string, type?: KeypairType): Promise<boolean> {
|
||||||
return sendMessage('pri(accounts.create.suri)', { name, password, suri, type });
|
return sendMessage('pri(accounts.create.suri)', { name, password, suri, type });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import type { ThemeProps } from '../types';
|
import type { ThemeProps } from '../types';
|
||||||
|
|
||||||
|
import { faUsb } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { faCodeBranch, faFileUpload, faKey, faPlusCircle, faQrcode } from '@fortawesome/free-solid-svg-icons';
|
import { faCodeBranch, faFileUpload, faKey, faPlusCircle, faQrcode } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React, { useCallback, useContext } from 'react';
|
import React, { useCallback, useContext } from 'react';
|
||||||
@ -10,6 +11,7 @@ import styled from 'styled-components';
|
|||||||
|
|
||||||
import { AccountContext, Link, MediaContext, Menu, MenuDivider, MenuItem } from '../components';
|
import { AccountContext, Link, MediaContext, Menu, MenuDivider, MenuItem } from '../components';
|
||||||
import useIsPopup from '../hooks/useIsPopup';
|
import useIsPopup from '../hooks/useIsPopup';
|
||||||
|
import { useLedger } from '../hooks/useLedger';
|
||||||
import useTranslation from '../hooks/useTranslation';
|
import useTranslation from '../hooks/useTranslation';
|
||||||
import { windowOpen } from '../messaging';
|
import { windowOpen } from '../messaging';
|
||||||
|
|
||||||
@ -19,16 +21,23 @@ interface Props extends ThemeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const jsonPath = '/account/restore-json';
|
const jsonPath = '/account/restore-json';
|
||||||
|
const ledgerPath = '/account/import-ledger';
|
||||||
|
|
||||||
function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
|
function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { master } = useContext(AccountContext);
|
const { master } = useContext(AccountContext);
|
||||||
const mediaAllowed = useContext(MediaContext);
|
const mediaAllowed = useContext(MediaContext);
|
||||||
|
const { isLedgerCapable, isLedgerEnabled } = useLedger();
|
||||||
const isPopup = useIsPopup();
|
const isPopup = useIsPopup();
|
||||||
|
|
||||||
const _openJson = useCallback((): void => {
|
const _openJson = useCallback(
|
||||||
windowOpen(jsonPath).catch(console.error);
|
() => windowOpen(jsonPath)
|
||||||
}, []);
|
, []);
|
||||||
|
|
||||||
|
const _onOpenLedgerConnect = useCallback(
|
||||||
|
() => windowOpen(ledgerPath),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
@ -72,12 +81,38 @@ function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
|
|||||||
<MenuItem className='menuItem'>
|
<MenuItem className='menuItem'>
|
||||||
<Link
|
<Link
|
||||||
isDisabled={!mediaAllowed}
|
isDisabled={!mediaAllowed}
|
||||||
|
title={ !mediaAllowed
|
||||||
|
? t<string>('Camera access must be first enabled in the settings')
|
||||||
|
: ''
|
||||||
|
}
|
||||||
to='/account/import-qr'
|
to='/account/import-qr'
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faQrcode} />
|
<FontAwesomeIcon icon={faQrcode} />
|
||||||
<span>{t<string>('Attach external QR-signer account')}</span>
|
<span>{t<string>('Attach external QR-signer account')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem className='menuItem ledger'>
|
||||||
|
{isLedgerEnabled
|
||||||
|
? <Link
|
||||||
|
isDisabled={!isLedgerCapable}
|
||||||
|
title={ (!isLedgerCapable && t<string>('Ledger devices can only be connected with Chrome browser')) || ''}
|
||||||
|
to={ledgerPath}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faUsb}
|
||||||
|
rotation={270}
|
||||||
|
/>
|
||||||
|
<span>{ t<string>('Attach ledger account')}</span>
|
||||||
|
</Link>
|
||||||
|
: <Link onClick={_onOpenLedgerConnect}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faUsb}
|
||||||
|
rotation={270}
|
||||||
|
/>
|
||||||
|
<span>{ t<string>('Connect Ledger device')}</span>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -102,6 +137,7 @@ export default React.memo(styled(MenuAdd)(({ theme }: Props) => `
|
|||||||
.svg-inline--fa {
|
.svg-inline--fa {
|
||||||
color: ${theme.iconNeutralColor};
|
color: ${theme.iconNeutralColor};
|
||||||
margin-right: 0.3rem;
|
margin-right: 0.3rem;
|
||||||
|
width: 0.875em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`));
|
`));
|
||||||
|
|||||||
@ -102,12 +102,12 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
|||||||
<MenuDivider/>
|
<MenuDivider/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className='setting'
|
className='setting'
|
||||||
title={t<string>('External QR accounts and Access')}
|
title={t<string>('External accounts and Access')}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={camera}
|
checked={camera}
|
||||||
className='checkbox'
|
className='checkbox camera'
|
||||||
label={t<string>('Allow Camera Access')}
|
label={t<string>('Allow QR Camera Access')}
|
||||||
onChange={setCamera}
|
onChange={setCamera}
|
||||||
/>
|
/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -183,6 +183,10 @@ export default React.memo(styled(MenuSettings)(({ theme }: Props) => `
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
&.ledger {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
color: ${theme.textColor};
|
color: ${theme.textColor};
|
||||||
}
|
}
|
||||||
|
|||||||
6
packages/extension-ui/src/util/legerChains.ts
Normal file
6
packages/extension-ui/src/util/legerChains.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import networks from '@polkadot/networks';
|
||||||
|
|
||||||
|
export default networks.filter((network) => network.hasLedgerSupport);
|
||||||
Loading…
Reference in New Issue
Block a user