Batch export (#666)

* .

* finish export all

* finish export all

* .

* fix linting errors (warning is pending on password decision)

* .

* resolve conflicts

* .

* .
This commit is contained in:
雪霁 2021-03-16 22:14:28 +08:00 committed by GitHub
parent 235c683476
commit e37fdcf6f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 253 additions and 31 deletions

View File

@ -4,7 +4,7 @@
import type { MetadataDef } from '@polkadot/extension-inject/types';
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/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 type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestBatchRestore, 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 chrome from '@polkadot/extension-inject/chrome';
@ -95,6 +95,14 @@ export default class Extension {
return { exportedJson: JSON.stringify(keyring.backupAccount(keyring.getPair(address), password)) };
}
private async accountsBatchExport ({ addresses, password }: RequestAccountBatchExport): Promise<ResponseAccountExport> {
return {
exportedJson: JSON.stringify(
await keyring.backupAccounts(addresses, password)
)
};
}
private accountsForget ({ address }: RequestAccountForget): boolean {
keyring.forgetAccount(address);
@ -261,6 +269,14 @@ export default class Extension {
}
}
private batchRestore ({ file, password }: RequestBatchRestore): void {
try {
keyring.restoreAccounts(file, password);
} catch (error) {
throw new Error((error as Error).message);
}
}
private jsonGetAccountInfo (json: KeyringPair$Json): ResponseJsonGetAccountInfo {
try {
const { address, meta: { genesisHash, name }, type } = keyring.createFromJson(json);
@ -500,6 +516,9 @@ export default class Extension {
case 'pri(accounts.export)':
return this.accountsExport(request as RequestAccountExport);
case 'pri(accounts.batchExport)':
return this.accountsBatchExport(request as RequestAccountBatchExport);
case 'pri(accounts.forget)':
return this.accountsForget(request as RequestAccountForget);
@ -539,6 +558,9 @@ export default class Extension {
case 'pri(json.restore)':
return this.jsonRestore(request as RequestJsonRestore);
case 'pri(json.batchRestore)':
return this.batchRestore(request as RequestBatchRestore);
case 'pri(json.account.info)':
return this.jsonGetAccountInfo(request as KeyringPair$Json);

View File

@ -7,6 +7,7 @@ import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderList,
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
import type { JsonRpcResponse } from '@polkadot/rpc-provider/types';
import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
import type { KeyringPairs$Json } from '@polkadot/ui-keyring/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import { TypeRegistry } from '@polkadot/types';
@ -78,6 +79,7 @@ export interface RequestSignatures {
'pri(accounts.create.suri)': [RequestAccountCreateSuri, boolean];
'pri(accounts.edit)': [RequestAccountEdit, boolean];
'pri(accounts.export)': [RequestAccountExport, ResponseAccountExport];
'pri(accounts.batchExport)': [RequestAccountBatchExport, ResponseAccountExport]
'pri(accounts.forget)': [RequestAccountForget, boolean];
'pri(accounts.show)': [RequestAccountShow, boolean];
'pri(accounts.tie)': [RequestAccountTie, boolean];
@ -92,6 +94,7 @@ export interface RequestSignatures {
'pri(derivation.create)': [RequestDeriveCreate, boolean];
'pri(derivation.validate)': [RequestDeriveValidate, ResponseDeriveValidate];
'pri(json.restore)': [RequestJsonRestore, void];
'pri(json.batchRestore)': [RequestBatchRestore, void];
'pri(json.account.info)': [KeyringPair$Json, ResponseJsonGetAccountInfo];
'pri(metadata.approve)': [RequestMetadataApprove, boolean];
'pri(metadata.get)': [string | null, MetadataDef | null];
@ -238,6 +241,11 @@ export interface RequestAccountExport {
password: string;
}
export interface RequestAccountBatchExport {
addresses: string[];
password: string;
}
export interface RequestAccountList {
anyType?: boolean;
}
@ -369,6 +377,11 @@ export interface RequestJsonRestore {
password: string;
}
export interface RequestBatchRestore {
file: KeyringPairs$Json;
password: string;
}
export interface ResponseJsonRestore {
error: string | null;
}

View File

@ -0,0 +1,131 @@
// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ThemeProps } from '../types';
import React, { useCallback, useContext, useState } from 'react';
import { RouteComponentProps, withRouter } from 'react-router';
import styled from 'styled-components';
import { AccountContext, ActionBar, ActionContext, ActionText, Button, InputWithLabel, Warning } from '../components';
import useTranslation from '../hooks/useTranslation';
import { exportAccounts } from '../messaging';
import { Header } from '../partials';
const MIN_LENGTH = 6;
interface Props extends RouteComponentProps, ThemeProps {
className?: string;
}
function ExportAll ({ className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { accounts } = useContext(AccountContext);
const onAction = useContext(ActionContext);
const [isBusy, setIsBusy] = useState(false);
const [pass, setPass] = useState('');
const [error, setError] = useState('');
const _goHome = useCallback(
() => onAction('/'),
[onAction]
);
const onPassChange = useCallback(
(password: string) => {
setPass(password);
setError('');
}
, []);
const _onExportAllButtonClick = useCallback(
(): void => {
setIsBusy(true);
exportAccounts(accounts.map((account) => account.address), pass)
.then(({ exportedJson }) => {
const element = document.createElement('a');
element.href = `data:text/plain;charset=utf-8,${exportedJson}`;
element.download = `batch_exported_account_${Date.now()}.json`;
element.click();
onAction('/');
})
.catch((error: Error) => {
console.error(error);
setError(error.message);
setIsBusy(false);
});
},
[accounts, onAction, pass]
);
return (
<>
<Header
showBackArrow
text={t<string>('All account')}
/>
<div className={className}>
<div className='actionArea'>
<InputWithLabel
data-export-all-password
disabled={isBusy}
isError={pass.length < MIN_LENGTH || !!error}
label={t<string>('password for encrypting all accounts')}
onChange={onPassChange}
type='password'
/>
{error && (
<Warning
isBelowInput
isDanger
>
{error}
</Warning>
)}
<Button
className='export-button'
data-export-button
isBusy={isBusy}
isDanger
isDisabled={pass.length === 0 || !!error}
onClick={_onExportAllButtonClick}
>
{t<string>('I want to export all my accounts')}
</Button>
<ActionBar className='withMarginTop'>
<ActionText
className='center'
onClick={_goHome}
text={t<string>('Cancel')}
/>
</ActionBar>
</div>
</div>
</>
);
}
export default withRouter(styled(ExportAll)`
.actionArea {
padding: 10px 24px;
}
.center {
margin: auto;
}
.export-button {
margin-top: 6px;
}
.movedWarning {
margin-top: 8px;
}
.withMarginTop {
margin-top: 4px;
}
`);

View File

@ -3,6 +3,7 @@
import type { ResponseJsonGetAccountInfo } from '@polkadot/extension-base/background/types';
import type { KeyringPair$Json } from '@polkadot/keyring/types';
import type { KeyringPairs$Json } from '@polkadot/ui-keyring/types';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import styled from 'styled-components';
@ -11,9 +12,10 @@ import { u8aToString } from '@polkadot/util';
import { AccountContext, ActionContext, Address, Button, InputFileWithLabel, InputWithLabel, Warning } from '../components';
import useTranslation from '../hooks/useTranslation';
import { jsonGetAccountInfo, jsonRestore } from '../messaging';
import { batchRestore, jsonGetAccountInfo, jsonRestore } from '../messaging';
import { Header } from '../partials';
import { DEFAULT_TYPE } from '../util/defaultType';
import { isKeyringPairs$Json } from '../util/typeGuards';
const acceptedFormats = ['application/json', 'text/plain'].join(', ');
@ -26,13 +28,14 @@ function Upload ({ className }: Props): React.ReactElement {
const { accounts } = useContext(AccountContext);
const onAction = useContext(ActionContext);
const [isBusy, setIsBusy] = useState(false);
const [{ address, genesisHash, name, type }, setAccountInfo] = useState<ResponseJsonGetAccountInfo>({ address: '', genesisHash: '', name: '', type: DEFAULT_TYPE });
const [accountsInfo, setAccountsInfo] = useState<ResponseJsonGetAccountInfo[]>([]);
const [password, setPassword] = useState<string>('');
const [isFileError, setFileError] = useState(false);
const [requirePassword, setRequirePassword] = useState(false);
const [isPasswordError, setIsPasswordError] = useState(false);
// don't use the info from the file directly
// rather use what comes from the background from jsonGetAccountInfo
const [file, setFile] = useState<KeyringPair$Json|undefined>(undefined);
const [file, setFile] = useState<KeyringPair$Json | KeyringPairs$Json | undefined>(undefined);
useEffect((): void => {
!accounts.length && onAction();
@ -47,34 +50,56 @@ function Upload ({ className }: Props): React.ReactElement {
const _onChangeFile = useCallback(
(file: Uint8Array): void => {
let json: KeyringPair$Json | undefined;
setAccountsInfo(() => []);
let json: KeyringPair$Json | KeyringPairs$Json | undefined;
try {
json = JSON.parse(u8aToString(file)) as KeyringPair$Json;
json = JSON.parse(u8aToString(file)) as KeyringPair$Json | KeyringPairs$Json;
setFile(json);
} catch (e) {
console.error(e);
setFileError(true);
}
json && jsonGetAccountInfo(json)
.then((accountInfo: ResponseJsonGetAccountInfo) => setAccountInfo(accountInfo))
.catch((e) => {
setFileError(true);
console.error(e);
if (json === undefined) {
return;
}
if (isKeyringPairs$Json(json)) {
setRequirePassword(true);
json.accounts.forEach((account) => {
setAccountsInfo((old) => [...old, {
address: account.address,
genesisHash: account.meta.genesisHash,
name: account.meta.name
} as ResponseJsonGetAccountInfo]);
});
} else {
setRequirePassword(true);
jsonGetAccountInfo(json)
.then((accountInfo) => setAccountsInfo((old) => [...old, accountInfo]))
.catch((e) => {
setFileError(true);
console.error(e);
});
}
}, []
);
const _onRestore = useCallback(
(): void => {
if (!file || !password) {
if (!file) {
return;
}
if (requirePassword && !password) {
return;
}
setIsBusy(true);
jsonRestore(file, password)
(isKeyringPairs$Json(file) ? batchRestore(file, password) : jsonRestore(file, password))
.then(() => { onAction('/'); })
.catch((e) => {
console.error(e);
@ -82,7 +107,7 @@ function Upload ({ className }: Props): React.ReactElement {
setIsPasswordError(true);
});
},
[file, onAction, password]
[file, onAction, password, requirePassword]
);
return (
@ -93,14 +118,15 @@ function Upload ({ className }: Props): React.ReactElement {
text={t<string>('Restore from JSON')}
/>
<div className={className}>
<div>
{accountsInfo.map(({ address, genesisHash, name, type = DEFAULT_TYPE }, index) => (
<Address
address={address}
genesisHash={genesisHash}
key={`${index}:${address}`}
name={name}
type={type}
/>
</div>
))}
<InputFileWithLabel
accept={acceptedFormats}
isError={isFileError}
@ -115,19 +141,23 @@ function Upload ({ className }: Props): React.ReactElement {
{t<string>('Invalid Json file')}
</Warning>
)}
<InputWithLabel
isError={isPasswordError}
label={t<string>('Password for this file')}
onChange={_onChangePass}
type='password'
/>
{isPasswordError && (
<Warning
isBelowInput
isDanger
>
{t<string>('Unable to decode using the supplied passphrase')}
</Warning>
{requirePassword && (
<div>
<InputWithLabel
isError={isPasswordError}
label={t<string>('Password for this file')}
onChange={_onChangePass}
type='password'
/>
{isPasswordError && (
<Warning
isBelowInput
isDanger
>
{t<string>('Unable to decode using the supplied passphrase')}
</Warning>
)}
</div>
)}
<Button
className='restoreButton'
@ -144,6 +174,6 @@ function Upload ({ className }: Props): React.ReactElement {
export default styled(Upload)`
.restoreButton {
margin-top: 6px;
margin-top: 16px;
}
`;

View File

@ -22,6 +22,7 @@ import Authorize from './Authorize';
import CreateAccount from './CreateAccount';
import Derive from './Derive';
import Export from './Export';
import ExportAll from './ExportAll';
import Forget from './Forget';
import ImportLedger from './ImportLedger';
import ImportQr from './ImportQr';
@ -136,6 +137,7 @@ export default function Popup (): React.ReactElement {
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</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-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-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-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>

View File

@ -5,6 +5,7 @@ import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MessageT
import type { Message } from '@polkadot/extension-base/types';
import type { Chain } from '@polkadot/extension-chains/types';
import type { KeyringPair$Json } from '@polkadot/keyring/types';
import type { KeyringPairs$Json } from '@polkadot/ui-keyring/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import { PORT_EXTENSION } from '@polkadot/extension-base/defaults';
@ -82,6 +83,10 @@ export async function exportAccount (address: string, password: string): Promise
return sendMessage('pri(accounts.export)', { address, password });
}
export async function exportAccounts (addresses: string[], password: string): Promise<{ exportedJson: string }> {
return sendMessage('pri(accounts.batchExport)', { addresses, password });
}
export async function validateAccount (address: string, password: string): Promise<boolean> {
return sendMessage('pri(accounts.validate)', { address, password });
}
@ -222,3 +227,7 @@ export async function jsonGetAccountInfo (json: KeyringPair$Json): Promise<Respo
export async function jsonRestore (file: KeyringPair$Json, password: string): Promise<void> {
return sendMessage('pri(json.restore)', { file, password });
}
export async function batchRestore (file: KeyringPairs$Json, password: string): Promise<void> {
return sendMessage('pri(json.batchRestore)', { file, password });
}

View File

@ -4,7 +4,7 @@
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, faFileExport, faFileUpload, faKey, faPlusCircle, faQrcode } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useContext } from 'react';
import styled from 'styled-components';
@ -62,6 +62,12 @@ function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
<MenuDivider />
</>
)}
<MenuItem className='menuItem'>
<Link to={'/account/export-all'}>
<FontAwesomeIcon icon={faFileExport} />
<span>{t<string>('Export all accounts')}</span>
</Link>
</MenuItem>
<MenuItem className='menuItem'>
<Link to='/account/import-seed'>
<FontAwesomeIcon icon={faKey} />

View File

@ -0,0 +1,9 @@
// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeyringPair$Json } from '@polkadot/keyring/types';
import type { KeyringPairs$Json } from '@polkadot/ui-keyring/types';
export function isKeyringPairs$Json (json: KeyringPair$Json | KeyringPairs$Json): json is KeyringPairs$Json {
return (json.encoding.content).includes('batch-pkcs8');
}