Account backup & restore functionality (#333)

* Account JSON backup & restore

* import isObject from correct package

* Fix isLocked logic

* Add "or"
This commit is contained in:
Jaco Greeff 2018-09-26 20:24:43 +02:00 committed by GitHub
parent e089a6c395
commit 818dfb2498
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 543 additions and 114 deletions

View File

@ -11,6 +11,8 @@
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.0.0",
"@polkadot/ui-app": "^0.20.19"
"@polkadot/ui-app": "^0.20.19",
"@types/file-saver": "^1.3.0",
"file-saver": "^1.3.8"
}
}

View File

@ -0,0 +1,150 @@
// Copyright 2017-2018 @polkadot/app-accounts authors & contributors
// This software may be modified and distributed under the terms
// of the ISC license. See the LICENSE file for details.
import { KeyringPair } from '@polkadot/util-keyring/types';
import { I18nProps } from '@polkadot/ui-app/types';
import FileSaver from 'file-saver';
import React from 'react';
import Button from '@polkadot/ui-app/Button';
import Modal from '@polkadot/ui-app/Modal';
import Password from '@polkadot/ui-app/Password';
import AddressSummary from '@polkadot/ui-app/AddressSummary';
import translate from './translate';
type Props = I18nProps & {
onClose: () => void,
pair: KeyringPair
};
type State = {
isPassValid: boolean,
password: string
};
class Backup extends React.PureComponent<Props, State> {
state: State;
constructor (props: Props) {
super(props);
this.state = {
isPassValid: false,
password: ''
};
}
render () {
return (
<Modal
size='tiny'
dimmer='inverted'
open
>
{this.renderContent()}
{this.renderButtons()}
</Modal>
);
}
renderButtons () {
const { onClose, t } = this.props;
const { isPassValid } = this.state;
return (
<Modal.Actions>
<Button.Group>
<Button
isNegative
onClick={onClose}
text={t('backup.close', {
defaultValue: 'Cancel'
})}
/>
<Button.Or />
<Button
isDisabled={!isPassValid}
isPrimary
onClick={this.doBackup}
text={t('backup.download', {
defaultValue: 'Download'
})}
/>
</Button.Group>
</Modal.Actions>
);
}
renderContent () {
const { pair, t } = this.props;
const { isPassValid, password } = this.state;
return [
<Modal.Header key='header'>
{t('backup.header', {
defaultValue: 'Backup account'
})}
</Modal.Header>,
<Modal.Content key='content'>
<AddressSummary
className='accounts--Modal-Address'
value={pair.address()}
/>
<div className='ui--row'>
<Password
isError={!isPassValid}
label={t('backup.password', {
defaultValue: 'unlock account using the password'
})}
onChange={this.onChangePass}
tabIndex={0}
value={password}
/>
</div>
</Modal.Content>
];
}
private doBackup = (): void => {
const { onClose, pair } = this.props;
const { password } = this.state;
if (!pair) {
return;
}
try {
if (!pair.isLocked()) {
pair.lock();
}
pair.decodePkcs8(password);
} catch (error) {
this.setState({ isPassValid: false });
return;
}
try {
const json = JSON.stringify(pair.toJson(password));
const blob = new Blob([json], { type: 'application/json; charset=utf-8' });
FileSaver.saveAs(blob, `${pair.address()}.json`);
} catch (error) {
this.setState({ isPassValid: false });
return;
}
onClose();
}
private onChangePass = (password: string) => {
this.setState({
isPassValid: password.length > 0 && password.length <= 32,
password
});
}
}
export default translate(Backup);

View File

@ -12,6 +12,7 @@ import Input from '@polkadot/ui-app/Input';
import InputAddress from '@polkadot/ui-app/InputAddress';
import keyring from '@polkadot/ui-keyring/index';
import Backup from './Backup';
import ChangePass from './ChangePass';
import Forgetting from './Forgetting';
import translate from './translate';
@ -24,6 +25,7 @@ type Props = I18nProps & {
type State = {
current: KeyringPair | null,
editedName: string,
isBackupOpen: boolean,
isEdited: boolean,
isForgetOpen: boolean,
isPasswordOpen: boolean
@ -66,6 +68,14 @@ class Editor extends React.PureComponent<Props, State> {
})}
/>
<Button.Group.Divider />
<Button
isDisabled={isEdited}
onClick={this.toggleBackup}
text={t('editor.backup', {
defaultValue: 'Backup'
})}
/>
<Button.Or />
<Button
isDisabled={isEdited}
onClick={this.togglePass}
@ -139,7 +149,7 @@ class Editor extends React.PureComponent<Props, State> {
}
renderModals () {
const { current, isForgetOpen, isPasswordOpen } = this.state;
const { current, isBackupOpen, isForgetOpen, isPasswordOpen } = this.state;
if (!current) {
return null;
@ -148,6 +158,16 @@ class Editor extends React.PureComponent<Props, State> {
const address = current.address();
const modals = [];
if (isBackupOpen) {
modals.push(
<Backup
key='modal-backup-account'
pair={current}
onClose={this.toggleBackup}
/>
);
}
if (isForgetOpen) {
modals.push(
<Forgetting
@ -178,6 +198,7 @@ class Editor extends React.PureComponent<Props, State> {
editedName: current
? current.getMeta().name || ''
: '',
isBackupOpen: false,
isEdited: false,
isForgetOpen: false,
isPasswordOpen: false
@ -204,6 +225,7 @@ class Editor extends React.PureComponent<Props, State> {
return {
current,
editedName,
isBackupOpen: false,
isEdited,
isForgetOpen: false,
isPasswordOpen: false
@ -253,6 +275,14 @@ class Editor extends React.PureComponent<Props, State> {
} as State);
}
toggleBackup = (): void => {
this.setState(
({ isBackupOpen }: State) => ({
isBackupOpen: !isBackupOpen
})
);
}
toggleForget = (): void => {
this.setState(
({ isForgetOpen }: State) => ({

View File

@ -0,0 +1,168 @@
// Copyright 2017-2018 @polkadot/app-accounts authors & contributors
// This software may be modified and distributed under the terms
// of the ISC license. See the LICENSE file for details.
import { KeyringPair$Json } from '@polkadot/util-keyring/types';
import { I18nProps } from '@polkadot/ui-app/types';
import React from 'react';
import AddressSummary from '@polkadot/ui-app/AddressSummary';
import Button from '@polkadot/ui-app/Button';
import InputFile from '@polkadot/ui-app/InputFile';
import { InputAddress } from '@polkadot/ui-app/InputAddress';
import Password from '@polkadot/ui-app/Password';
import createPair from '@polkadot/util-keyring/pair';
import decodeAddress from '@polkadot/util-keyring/address/decode';
import isHex from '@polkadot/util/is/hex';
import isObject from '@polkadot/util/is/object';
import hexToU8a from '@polkadot/util/hex/toU8a';
import u8aToUtf8 from '@polkadot/util/u8a/toUtf8';
import keyring from '@polkadot/ui-keyring/index';
import translate from './translate';
type Props = I18nProps & {
onBack: () => void
};
type State = {
isFileValid: boolean,
isPassValid: boolean,
json: KeyringPair$Json | null,
password: string
};
class Restore extends React.PureComponent<Props, State> {
state: State;
constructor (props: Props) {
super(props);
this.state = {
json: null,
isFileValid: false,
isPassValid: false,
password: ''
};
}
render () {
const { t } = this.props;
const { isFileValid, isPassValid, json } = this.state;
return (
<div className='accounts--Restore'>
<div className='ui--grid'>
<AddressSummary
className='shrink'
value={
isFileValid && json
? json.address
: null
}
/>
{this.renderInput()}
</div>
<Button.Group>
<Button
isDisabled={!isFileValid || !isPassValid}
isPrimary
onClick={this.onSave}
text={t('restore.save', {
defaultValue: 'Restore'
})}
/>
</Button.Group>
</div>
);
}
renderInput () {
const { t } = this.props;
const { isFileValid, isPassValid, password } = this.state;
return (
<div className='grow'>
<div className='ui--row'>
<Password
className='full'
isError={!isPassValid}
label={t('restore.password', {
defaultValue: 'decrypt keyfile using the password'
})}
onChange={this.onChangePass}
value={password}
/>
</div>
<div className='ui--row'>
<InputFile
className='full'
isError={!isFileValid}
label={t('restore.json', {
defaultValue: 'previously backed-up json keyfile'
})}
onChange={this.onChangeFile}
/>
</div>
</div>
);
}
onChangeFile = (file: Uint8Array): void => {
try {
const json = JSON.parse(u8aToUtf8(file));
const isFileValid = decodeAddress(json.address).length === 32 &&
isHex(json.encoded) &&
isObject(json.meta) &&
json.encoding.content === 'pkcs8';
this.setState({
isFileValid,
json
});
} catch (error) {
this.setState({
isFileValid: false,
json: null
});
}
}
onChangePass = (password: string): void => {
this.setState({
isPassValid: password.length > 0 && password.length <= 32,
password
});
}
onSave = (): void => {
const { onBack } = this.props;
const { json, password } = this.state;
if (!json) {
return;
}
try {
const pair = createPair(
{
publicKey: decodeAddress(json.address),
secretKey: new Uint8Array()
},
json.meta,
hexToU8a(json.encoded)
);
pair.decodePkcs8(password);
pair.lock();
keyring.addAccountPair(pair, password);
InputAddress.setLastValue('account', pair.address());
onBack();
} catch (error) {
this.setState({ isPassValid: false });
}
}
}
export default translate(Restore);

View File

@ -14,6 +14,7 @@ import withObservableBase from '@polkadot/ui-react-rx/with/observableBase';
import Creator from './Creator';
import Editor from './Editor';
import Restore from './Restore';
import translate from './translate';
type Props = I18nProps & {
@ -21,7 +22,7 @@ type Props = I18nProps & {
basePath: string
};
type Actions = 'create' | 'edit';
type Actions = 'create' | 'edit' | 'restore';
type State = {
action: Actions,
@ -32,7 +33,8 @@ type State = {
// FIXME React-router would probably be the best route, not home-grown
const Components: { [index: string]: React.ComponentType<any> } = {
'create': Creator,
'edit': Editor
'edit': Editor,
'restore': Restore
};
class AccountsApp extends React.PureComponent<Props, State> {
@ -56,6 +58,10 @@ class AccountsApp extends React.PureComponent<Props, State> {
{
name: 'create',
text: t('app.create', { defaultValue: 'Create account' })
},
{
name: 'restore',
text: t('app.restore', { defaultValue: 'Restore account' })
}
]
};

View File

@ -21,7 +21,7 @@ export type Props = I18nProps & {
balance?: BN | Array<BN>,
children?: React.ReactNode,
name?: string,
value: string | Uint8Array,
value: string | Uint8Array | null,
withBalance?: boolean,
identIconSize?: number,
isShort?: boolean
@ -45,7 +45,7 @@ class AddressSummary extends React.PureComponent<Props, State> {
static getDerivedStateFromProps ({ value }: Props, { address, publicKey, shortValue }: State): State {
try {
publicKey = addressDecode(value);
publicKey = addressDecode(value as string);
address = addressEncode(publicKey);
shortValue = toShortAddress(address);
} catch (error) {
@ -121,9 +121,9 @@ class AddressSummary extends React.PureComponent<Props, State> {
protected renderCopy () {
const { withCopy = true } = this.props;
const { address } = this.state;
const { address, publicKey } = this.state;
if (!withCopy) {
if (!withCopy || !publicKey) {
return null;
}

View File

@ -0,0 +1,104 @@
// Copyright 2017-2018 @polkadot/ui-app authors & contributors
// This software may be modified and distributed under the terms
// of the ISC license. See the LICENSE file for details.
import { TranslationFunction } from 'i18next';
import { BareProps } from '@polkadot/ui-app/types';
import React from 'react';
import Dropzone from 'react-dropzone';
import Labelled from './Labelled';
import classes from './util/classes';
import translate from './translate';
type Props = BareProps & {
isDisabled?: boolean,
isError?: boolean,
label: string,
onChange?: (contents: Uint8Array) => void,
placeholder?: string,
t: TranslationFunction,
withLabel?: boolean
};
type State = {
file?: {
name: string,
size: number
}
};
type LoadEvent = {
target: {
result: ArrayBuffer
}
};
class InputFile extends React.PureComponent<Props, State> {
state: State = {};
render () {
const { className, isDisabled, isError = false, label, placeholder, t, withLabel } = this.props;
const { file } = this.state;
return (
<Labelled
label={label}
withLabel={withLabel}
>
<Dropzone
className={classes('ui--InputFile', isError ? 'error' : '', className)}
disabled={isDisabled}
multiple={false}
onDrop={this.onDrop}
>
<div className='label'>
{
!file
? placeholder || t('file.dnd', {
defaultValue: 'drag and drop the file here'
})
: placeholder || t('file.description', {
defaultValue: '{{name}} ({{size}} bytes)',
replace: file
})
}
</div>
</Dropzone>
</Labelled>
);
}
onDrop = (files: Array<File>) => {
const { onChange } = this.props;
files.forEach((file) => {
const reader = new FileReader();
reader.onabort = () => {
// ignore
};
reader.onerror = () => {
// ignore
};
// @ts-ignore ummm... events are not properly specified here?
reader.onload = ({ target: { result } }: LoadEvent) => {
const data = new Uint8Array(result);
onChange && onChange(data);
this.setState({
file: {
name: file.name,
size: data.length
}
});
};
reader.readAsArrayBuffer(file);
});
}
}
export default translate(InputFile);

View File

@ -2,15 +2,12 @@
// This software may be modified and distributed under the terms
// of the ISC license. See the LICENSE file for details.
import { TranslationFunction } from 'i18next';
import { BareProps } from '@polkadot/ui-app/types';
import React from 'react';
import Dropzone from 'react-dropzone';
import classes from '../../util/classes';
import translate from '../../translate';
import Base from './Base';
import InputFile from '../../InputFile';
import Bare from './Bare';
type Props = BareProps & {
isDisabled?: boolean,
@ -18,88 +15,27 @@ type Props = BareProps & {
label: string,
onChange?: (contents: Uint8Array) => void,
placeholder?: string,
t: TranslationFunction,
withLabel?: boolean
};
type State = {
file?: {
name: string,
size: number
}
};
type LoadEvent = {
target: { // eslint-disable-line react/no-unused-prop-types
result: ArrayBuffer
}
};
class BytesFile extends React.PureComponent<Props, State> {
state: State = {};
export default class File extends React.PureComponent<Props> {
render () {
const { className, isDisabled, isError = false, label, placeholder, t, withLabel } = this.props;
const { file } = this.state;
const { className, isDisabled, isError = false, label, onChange, placeholder, style, withLabel } = this.props;
return (
<Base
label={label}
size='full'
withLabel={withLabel}
<Bare
className={className}
style={style}
>
<Dropzone
className={classes('ui--Param-File', isError ? 'error' : '', className)}
disabled={isDisabled}
multiple={false}
onDrop={this.onDrop}
>
<div className='label'>
{
!file
? placeholder || t('file.dnd', {
defaultValue: 'drag and drop the file here'
})
: placeholder || t('file.description', {
defaultValue: '{{name}} ({{size}} bytes)',
replace: file
})
}
</div>
</Dropzone>
</Base>
<InputFile
isDisabled={isDisabled}
isError={isError}
label={label}
onChange={onChange}
placeholder={placeholder}
withLabel={withLabel}
/>
</Bare>
);
}
onDrop = (files: Array<File>) => {
const { onChange } = this.props;
files.forEach((file) => {
const reader = new FileReader();
reader.onabort = () => {
// ignore
};
reader.onerror = () => {
// ignore
};
// @ts-ignore ummm... events are not properly specified here?
reader.onload = ({ target: { result } }: LoadEvent) => {
const data = new Uint8Array(result);
onChange && onChange(data);
this.setState({
file: {
name: file.name,
size: data.length
}
});
};
reader.readAsArrayBuffer(file);
});
}
}
export default translate(BytesFile);

View File

@ -11,31 +11,6 @@
padding-left: 2em;
}
.ui--Param-File {
align-items: center;
border: 1px dashed rgba(34, 36, 38, 0.15);
border-radius: 0.28571429rem;
display: flex;
justify-content: center;
font-size: 1.2em;
height: 5em;
margin-bottom: 0.5rem;
width: 100% !important;
}
.ui--Param-File.true {
border-style: solid;
}
.ui--Params-File:hover {
background: rgba(200, 200, 200, 0.8);
cursor: pointer;
}
.ui--Param-File .label {
color: rgba(0, 0, 0, .6);
}
.ui--Param-text {
display: inline-block;
font-size: 1rem;

View File

@ -2,6 +2,8 @@
/* This software may be modified and distributed under the terms
/* of the ISC license. See the LICENSE file for details. */
@import './partials/_variables.css';
.ui--output {
background: #f9f9f9;
border-radius: 4px;
@ -26,7 +28,7 @@
}
.ui--Button-Group .button {
border-radius: .28571429rem !important;
border-radius: $small-corner !important;
}
header .ui--Button-Group {
@ -166,3 +168,29 @@ header .ui--Button-Group {
.ui--CopyButton {
cursor: copy !important;
}
.ui--InputFile {
align-items: center;
border: 1px dashed rgba(34, 36, 38, 0.15);
border-radius: $small-corner;
display: flex;
justify-content: center;
font-size: 1.2em;
height: 5em;
margin-bottom: 0.5rem;
width: 100% !important;
&.error {
background: #fff6f6;
border-color: #e0b4b4;
}
&:hover {
background: #fefefe;
cursor: pointer;
}
.label {
color: rgba(0, 0, 0, .6);
}
}

View File

@ -0,0 +1,16 @@
// Copyright 2017-2018 @polkadot/ui-keyring authors & contributors
// This software may be modified and distributed under the terms
// of the ISC license. See the LICENSE file for details.
import { KeyringPair } from '@polkadot/util-keyring/types';
import { State } from '../types';
import saveAccount from './save';
export default function addAccountPair (state: State, pair: KeyringPair, password: string): KeyringPair {
state.keyring.addPair(pair);
saveAccount(state, pair, password);
return pair;
}

View File

@ -12,6 +12,7 @@ import accounts from './observable/accounts';
import addresses from './observable/addresses';
import development from './observable/development';
import loadAll from './loadAll';
import addAccountPair from './account/addPair';
import createAccount from './account/create';
import forgetAccount from './account/forget';
import isAvailable from './isAvailable';
@ -33,7 +34,11 @@ const state: State = {
loadAll(state);
// FIXME The quicker we get in https://github.com/polkadot-js/apps/issues/138
// the better, this is now completely out of control
export default ({
addAccountPair: (pair: KeyringPair, password: string): KeyringPair =>
addAccountPair(state, pair, password),
createAccount: (seed: Uint8Array, password?: string, meta?: KeyringPair$Meta): KeyringPair =>
createAccount(state, seed, password, meta),
encryptAccount: (pair: KeyringPair, password: string): void =>

View File

@ -35,6 +35,7 @@ export type State = {
};
export type KeyringInstance = {
addAccountPair: (pair: KeyringPair, password: string) => KeyringPair,
createAccount: (seed: Uint8Array, password?: string, meta?: KeyringPair$Meta) => KeyringPair,
encryptAccount: (pair: KeyringPair, password: string) => void,
forgetAccount: (address: string) => void,

View File

@ -1630,6 +1630,10 @@
version "1.2.0"
resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86"
"@types/file-saver@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-1.3.0.tgz#0ef213077e704fc3f4e7a86cfd31c9de4f4f47a7"
"@types/fs-extra@^5.0.3":
version "5.0.4"
resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-5.0.4.tgz#b971134d162cc0497d221adde3dbb67502225599"
@ -4869,6 +4873,10 @@ file-loader@^2.0.0:
loader-utils "^1.0.2"
schema-utils "^1.0.0"
file-saver@^1.3.8:
version "1.3.8"
resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-1.3.8.tgz#e68a30c7cb044e2fb362b428469feb291c2e09d8"
filename-regex@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"