From e37fdcf6f9fa6c59d5fe96bb12bfae325220a887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=AA=E9=9C=81?= Date: Tue, 16 Mar 2021 22:14:28 +0800 Subject: [PATCH] Batch export (#666) * . * finish export all * finish export all * . * fix linting errors (warning is pending on password decision) * . * resolve conflicts * . * . --- .../src/background/handlers/Extension.ts | 24 +++- .../extension-base/src/background/types.ts | 13 ++ packages/extension-ui/src/Popup/ExportAll.tsx | 131 ++++++++++++++++++ .../extension-ui/src/Popup/RestoreJson.tsx | 88 ++++++++---- packages/extension-ui/src/Popup/index.tsx | 2 + packages/extension-ui/src/messaging.ts | 9 ++ .../extension-ui/src/partials/MenuAdd.tsx | 8 +- packages/extension-ui/src/util/typeGuards.ts | 9 ++ 8 files changed, 253 insertions(+), 31 deletions(-) create mode 100644 packages/extension-ui/src/Popup/ExportAll.tsx create mode 100644 packages/extension-ui/src/util/typeGuards.ts diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index 47eda02d..f929af80 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -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 { + 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); diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index 192343ac..f04b6009 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -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; } diff --git a/packages/extension-ui/src/Popup/ExportAll.tsx b/packages/extension-ui/src/Popup/ExportAll.tsx new file mode 100644 index 00000000..0cd1ef39 --- /dev/null +++ b/packages/extension-ui/src/Popup/ExportAll.tsx @@ -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 { + 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 ( + <> +
('All account')} + /> +
+
+ ('password for encrypting all accounts')} + onChange={onPassChange} + type='password' + /> + {error && ( + + {error} + + )} + + + ('Cancel')} + /> + +
+
+ + ); +} + +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; + } +`); diff --git a/packages/extension-ui/src/Popup/RestoreJson.tsx b/packages/extension-ui/src/Popup/RestoreJson.tsx index ad2cb256..ea42731e 100644 --- a/packages/extension-ui/src/Popup/RestoreJson.tsx +++ b/packages/extension-ui/src/Popup/RestoreJson.tsx @@ -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({ address: '', genesisHash: '', name: '', type: DEFAULT_TYPE }); + const [accountsInfo, setAccountsInfo] = useState([]); const [password, setPassword] = useState(''); 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(undefined); + const [file, setFile] = useState(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('Restore from JSON')} />
-
+ {accountsInfo.map(({ address, genesisHash, name, type = DEFAULT_TYPE }, index) => (
-
+ ))} ('Invalid Json file')} )} - ('Password for this file')} - onChange={_onChangePass} - type='password' - /> - {isPasswordError && ( - - {t('Unable to decode using the supplied passphrase')} - + {requirePassword && ( +
+ ('Password for this file')} + onChange={_onChangePass} + type='password' + /> + {isPasswordError && ( + + {t('Unable to decode using the supplied passphrase')} + + )} +
)}