Allow signing via Qr (#4881)

* QR code scanning in-place

* QrScan component

* Update tests

* s/store/createStore/ in props

* Create of actual accountsInfo entry

* Exteral/hardware forget, no password change

* Add external accounts to accounts list

* Render external accounts in section (along with hw)

* Manul test bugfixes

* Display Qr code for tx signing

* Align QR code

* Hints for QR operations

* Generate actual qr codes based on tx data

* Add leading 0x if not found

* Update tests for 0x leading addition

* from & rpl without leading 0x

* Auto-detect QR code size (input length)

* Confirm raw

* WIP (lots of logging)

* WIP

* Chain-replay protection

* Readability

* Re-add r: chainId

* s = 0, r = 0, v = chainId

* Update eth_signTransaction to send transaction object

* And it actually works.

* Externalise createUnsigned/createSigned

* Check for nonce updates (future: subscriptions)

* Allow gas overrides

* Expose formatted condition

* Extract calculation (cap at 40)

* Remove debug log

* Fix rename linting

* Allow for signing hash & rlp (App support forthcoming)

* WIP

* User original qrcode-generator package

* Complete hash + rlp signing

* Accurate QR code size calculation

* Simplify type calculation

* R-eactivate current mobile interface (TODO for new)

* Move napa to dep

* Allow external accounts visibility in dapps

* Allow napa install on CI

* Allow new signTransaction & signTransactionHash functionality
This commit is contained in:
Jaco Greeff 2017-03-31 23:36:24 +02:00 committed by Gav Wood
parent cbaa7fdee6
commit 1987dad527
51 changed files with 1304 additions and 256 deletions

View File

@ -1 +1,2 @@
save-prefix='' save-prefix=''
unsafe-perm=true

View File

@ -26,6 +26,7 @@
"Promise" "Promise"
], ],
"scripts": { "scripts": {
"install": "napa",
"analize": "npm run analize:lib && npm run analize:dll && npm run analize:app", "analize": "npm run analize:lib && npm run analize:dll && npm run analize:app",
"analize:app": "WPANALIZE=1 webpack --config webpack/app --json > .build/analize.app.json && cat .build/analize.app.json | webpack-bundle-size-analyzer", "analize:app": "WPANALIZE=1 webpack --config webpack/app --json > .build/analize.app.json && cat .build/analize.app.json | webpack-bundle-size-analyzer",
"analize:lib": "WPANALIZE=1 webpack --config webpack/libraries --json > .build/analize.lib.json && cat .build/analize.lib.json | webpack-bundle-size-analyzer", "analize:lib": "WPANALIZE=1 webpack --config webpack/libraries --json > .build/analize.lib.json && cat .build/analize.lib.json | webpack-bundle-size-analyzer",
@ -63,6 +64,9 @@
"test:npm": "(cd .npmjs && npm i) && node test/npmParity && node test/npmJsonRpc && (rm -rf .npmjs/node_modules)", "test:npm": "(cd .npmjs && npm i) && node test/npmParity && node test/npmJsonRpc && (rm -rf .npmjs/node_modules)",
"prepush": "npm run lint:cached" "prepush": "npm run lint:cached"
}, },
"napa": {
"qrcode-generator": "kazuhikoarase/qrcode-generator"
},
"devDependencies": { "devDependencies": {
"babel-cli": "6.23.0", "babel-cli": "6.23.0",
"babel-core": "6.23.1", "babel-core": "6.23.1",
@ -182,10 +186,10 @@
"mobx-react": "4.0.3", "mobx-react": "4.0.3",
"mobx-react-devtools": "4.2.10", "mobx-react-devtools": "4.2.10",
"moment": "2.17.0", "moment": "2.17.0",
"napa": "2.3.0",
"phoneformat.js": "1.0.3", "phoneformat.js": "1.0.3",
"promise-worker": "1.1.1", "promise-worker": "1.1.1",
"push.js": "0.0.11", "push.js": "0.0.11",
"qrcode-npm": "0.0.3",
"qs": "6.3.0", "qs": "6.3.0",
"react": "15.4.2", "react": "15.4.2",
"react-ace": "4.1.0", "react-ace": "4.1.0",
@ -198,6 +202,7 @@
"react-intl": "2.1.5", "react-intl": "2.1.5",
"react-markdown": "2.4.4", "react-markdown": "2.4.4",
"react-portal": "3.0.0", "react-portal": "3.0.0",
"react-qr-reader": "1.0.3",
"react-redux": "4.4.6", "react-redux": "4.4.6",
"react-router": "3.0.0", "react-router": "3.0.0",
"react-router-redux": "4.0.7", "react-router-redux": "4.0.7",

View File

@ -288,9 +288,9 @@ export default class Eth {
.execute('eth_sign', inAddress(address), inHash(hash)); .execute('eth_sign', inAddress(address), inHash(hash));
} }
signTransaction () { signTransaction (options) {
return this._transport return this._transport
.execute('eth_signTransaction'); .execute('eth_signTransaction', inOptions(options));
} }
submitHashrate (hashrate, clientId) { submitHashrate (hashrate, clientId) {

View File

@ -25,11 +25,11 @@ import styles from '../createAccount.css';
@observer @observer
export default class AccountDetails extends Component { export default class AccountDetails extends Component {
static propTypes = { static propTypes = {
store: PropTypes.object.isRequired createStore: PropTypes.object.isRequired
} }
render () { render () {
const { address, description, name } = this.props.store; const { address, description, name } = this.props.createStore;
return ( return (
<div className={ styles.details }> <div className={ styles.details }>
@ -79,7 +79,7 @@ export default class AccountDetails extends Component {
} }
renderPhrase () { renderPhrase () {
const { phrase } = this.props.store; const { phrase } = this.props.createStore;
if (!phrase) { if (!phrase) {
return null; return null;

View File

@ -28,7 +28,7 @@ function render () {
store = createStore(); store = createStore();
component = shallow( component = shallow(
<AccountDetails <AccountDetails
store={ store } createStore={ store }
/> />
); );

View File

@ -26,11 +26,11 @@ import styles from '../createAccount.css';
@observer @observer
export default class AccountDetailsGeth extends Component { export default class AccountDetailsGeth extends Component {
static propTypes = { static propTypes = {
store: PropTypes.object.isRequired createStore: PropTypes.object.isRequired
} }
render () { render () {
const { gethAccountsAvailable, gethImported } = this.props.store; const { gethAccountsAvailable, gethImported } = this.props.createStore;
const accounts = gethAccountsAvailable.filter((account) => gethImported.includes(account.address)); const accounts = gethAccountsAvailable.filter((account) => gethImported.includes(account.address));

View File

@ -28,7 +28,7 @@ function render () {
store = createStore(); store = createStore();
component = shallow( component = shallow(
<AccountDetailsGeth <AccountDetailsGeth
store={ store } createStore={ store }
/> />
); );

View File

@ -22,13 +22,13 @@ import { VaultSelect } from '~/ui';
@observer @observer
export default class ChangeVault extends Component { export default class ChangeVault extends Component {
static propTypes = { static propTypes = {
store: PropTypes.object.isRequired, createStore: PropTypes.object.isRequired,
vaultStore: PropTypes.object vaultStore: PropTypes.object
} }
render () { render () {
const { store, vaultStore } = this.props; const { createStore, vaultStore } = this.props;
const { vaultName } = store; const { vaultName } = createStore;
if (!vaultStore || vaultStore.vaultsOpened.length === 0) { if (!vaultStore || vaultStore.vaultsOpened.length === 0) {
return null; return null;
@ -44,8 +44,8 @@ export default class ChangeVault extends Component {
} }
onSelect = (vaultName) => { onSelect = (vaultName) => {
const { store } = this.props; const { createStore } = this.props;
store.setVaultName(vaultName); createStore.setVaultName(vaultName);
} }
} }

View File

@ -45,7 +45,7 @@ function createVaultStore () {
function render () { function render () {
component = shallow( component = shallow(
<ChangeVault <ChangeVault
store={ createStore() } createStore={ createStore() }
vaultStore={ createVaultStore() } vaultStore={ createVaultStore() }
/> />
); );

View File

@ -54,6 +54,21 @@ const TYPES = [
), ),
key: 'fromPhrase' key: 'fromPhrase'
}, },
{
description: (
<FormattedMessage
id='createAccount.creationType.fromQr.description'
defaultMessage='Attach an externally managed account via QR code'
/>
),
label: (
<FormattedMessage
id='createAccount.creationType.fromQr.label'
defaultMessage='External Account'
/>
),
key: 'fromQr'
},
{ {
description: ( description: (
<FormattedMessage <FormattedMessage
@ -84,21 +99,6 @@ const TYPES = [
), ),
key: 'fromJSON' key: 'fromJSON'
}, },
{
description: (
<FormattedMessage
id='createAccount.creationType.fromPresale.description'
defaultMessage='Import an Ethereum presale wallet file with the original password'
/>
),
label: (
<FormattedMessage
id='createAccount.creationType.fromPresale.label'
defaultMessage='Presale wallet'
/>
),
key: 'fromPresale'
},
{ {
description: ( description: (
<FormattedMessage <FormattedMessage
@ -113,17 +113,32 @@ const TYPES = [
/> />
), ),
key: 'fromRaw' key: 'fromRaw'
},
{
description: (
<FormattedMessage
id='createAccount.creationType.fromPresale.description'
defaultMessage='Import an Ethereum presale wallet file with the original password'
/>
),
label: (
<FormattedMessage
id='createAccount.creationType.fromPresale.label'
defaultMessage='Presale wallet'
/>
),
key: 'fromPresale'
} }
]; ];
@observer @observer
export default class CreationType extends Component { export default class CreationType extends Component {
static propTypes = { static propTypes = {
store: PropTypes.object.isRequired createStore: PropTypes.object.isRequired
} }
render () { render () {
const { createType } = this.props.store; const { createType } = this.props.createStore;
return ( return (
<div> <div>
@ -157,7 +172,7 @@ export default class CreationType extends Component {
<div className={ styles.selectItem }> <div className={ styles.selectItem }>
<TypeIcon <TypeIcon
className={ styles.icon } className={ styles.icon }
store={ this.props.store } createStore={ this.props.createStore }
type={ item.key } type={ item.key }
/> />
<Title <Title
@ -171,21 +186,21 @@ export default class CreationType extends Component {
} }
isSelected = (item) => { isSelected = (item) => {
const { createType } = this.props.store; const { createType } = this.props.createStore;
return item.key === createType; return item.key === createType;
} }
onChange = (item) => { onChange = (item) => {
const { store } = this.props; const { createStore } = this.props;
store.setCreateType(item.key); createStore.setCreateType(item.key);
} }
onSelect = (item) => { onSelect = (item) => {
const { store } = this.props; const { createStore } = this.props;
store.setCreateType(item.key); createStore.setCreateType(item.key);
store.nextStage(); createStore.nextStage();
} }
} }

View File

@ -29,7 +29,7 @@ function render () {
store = createStore(); store = createStore();
component = shallow( component = shallow(
<CreationType <CreationType
store={ store } createStore={ store }
/> />
); );
instance = component.instance(); instance = component.instance();

View File

@ -31,7 +31,7 @@ import styles from '../createAccount.css';
export default class CreateAccount extends Component { export default class CreateAccount extends Component {
static propTypes = { static propTypes = {
newError: PropTypes.func.isRequired, newError: PropTypes.func.isRequired,
store: PropTypes.object.isRequired, createStore: PropTypes.object.isRequired,
vaultStore: PropTypes.object vaultStore: PropTypes.object
} }
@ -45,7 +45,7 @@ export default class CreateAccount extends Component {
} }
render () { render () {
const { name, nameError, password, passwordRepeat, passwordRepeatError, passwordHint } = this.props.store; const { name, nameError, password, passwordRepeat, passwordRepeatError, passwordHint } = this.props.createStore;
return ( return (
<Form> <Form>
@ -126,7 +126,7 @@ export default class CreateAccount extends Component {
</div> </div>
<PasswordStrength input={ password } /> <PasswordStrength input={ password } />
<ChangeVault <ChangeVault
store={ this.props.store } createStore={ this.props.createStore }
vaultStore={ this.props.vaultStore } vaultStore={ this.props.vaultStore }
/> />
{ this.renderIdentitySelector() } { this.renderIdentitySelector() }
@ -203,16 +203,16 @@ export default class CreateAccount extends Component {
} }
createIdentities = () => { createIdentities = () => {
const { store } = this.props; const { createStore } = this.props;
return store return createStore
.createIdentities() .createIdentities()
.then((accounts) => { .then((accounts) => {
const selectedAddress = Object.keys(accounts)[0]; const selectedAddress = Object.keys(accounts)[0];
const { phrase } = accounts[selectedAddress]; const { phrase } = accounts[selectedAddress];
store.setAddress(selectedAddress); createStore.setAddress(selectedAddress);
store.setPhrase(phrase); createStore.setPhrase(phrase);
this.setState({ this.setState({
accounts, accounts,
@ -225,7 +225,7 @@ export default class CreateAccount extends Component {
} }
onChangeIdentity = (event) => { onChangeIdentity = (event) => {
const { store } = this.props; const { createStore } = this.props;
const selectedAddress = event.target.value || event.target.getAttribute('value'); const selectedAddress = event.target.value || event.target.getAttribute('value');
if (!selectedAddress) { if (!selectedAddress) {
@ -235,32 +235,32 @@ export default class CreateAccount extends Component {
this.setState({ selectedAddress }, () => { this.setState({ selectedAddress }, () => {
const { phrase } = this.state.accounts[selectedAddress]; const { phrase } = this.state.accounts[selectedAddress];
store.setAddress(selectedAddress); createStore.setAddress(selectedAddress);
store.setPhrase(phrase); createStore.setPhrase(phrase);
}); });
} }
onEditPasswordHint = (event, passwordHint) => { onEditPasswordHint = (event, passwordHint) => {
const { store } = this.props; const { createStore } = this.props;
store.setPasswordHint(passwordHint); createStore.setPasswordHint(passwordHint);
} }
onEditAccountName = (event, name) => { onEditAccountName = (event, name) => {
const { store } = this.props; const { createStore } = this.props;
store.setName(name); createStore.setName(name);
} }
onEditPassword = (event, password) => { onEditPassword = (event, password) => {
const { store } = this.props; const { createStore } = this.props;
store.setPassword(password); createStore.setPassword(password);
} }
onEditPasswordRepeat = (event, password) => { onEditPasswordRepeat = (event, password) => {
const { store } = this.props; const { createStore } = this.props;
store.setPasswordRepeat(password); createStore.setPasswordRepeat(password);
} }
} }

View File

@ -32,7 +32,7 @@ function render () {
store = createStore(); store = createStore();
component = shallow( component = shallow(
<NewAccount <NewAccount
store={ store } createStore={ store }
/>, />,
{ {
context: { api } context: { api }

View File

@ -30,11 +30,11 @@ export default class NewGeth extends Component {
} }
static propTypes = { static propTypes = {
store: PropTypes.object.isRequired createStore: PropTypes.object.isRequired
} }
render () { render () {
const { gethAccountsAvailable, gethAddresses } = this.props.store; const { gethAccountsAvailable, gethAddresses } = this.props.createStore;
return gethAccountsAvailable.length return gethAccountsAvailable.length
? ( ? (
@ -84,14 +84,14 @@ export default class NewGeth extends Component {
} }
isSelected = (account) => { isSelected = (account) => {
const { gethAddresses } = this.props.store; const { gethAddresses } = this.props.createStore;
return gethAddresses.includes(account.address); return gethAddresses.includes(account.address);
} }
onSelect = (account) => { onSelect = (account) => {
const { store } = this.props; const { createStore } = this.props;
store.selectGethAccount(account.address); createStore.selectGethAccount(account.address);
} }
} }

View File

@ -30,7 +30,7 @@ function render () {
store = createStore(); store = createStore();
component = shallow( component = shallow(
<NewGeth <NewGeth
store={ store } createStore={ store }
/> />
); );
instance = component.instance(); instance = component.instance();

View File

@ -26,13 +26,13 @@ import styles from '../createAccount.css';
@observer @observer
export default class NewImport extends Component { export default class NewImport extends Component {
static propTypes = { static propTypes = {
store: PropTypes.object.isRequired, createStore: PropTypes.object.isRequired,
vaultStore: PropTypes.object vaultStore: PropTypes.object
} }
render () { render () {
const { name, nameError, password, passwordHint } = this.props.store; const { name, nameError, password, passwordHint } = this.props.createStore;
return ( return (
<Form> <Form>
@ -92,7 +92,7 @@ export default class NewImport extends Component {
</div> </div>
</div> </div>
<ChangeVault <ChangeVault
store={ this.props.store } createStore={ this.props.createStore }
vaultStore={ this.props.vaultStore } vaultStore={ this.props.vaultStore }
/> />
{ this.renderFileSelector() } { this.renderFileSelector() }
@ -101,7 +101,7 @@ export default class NewImport extends Component {
} }
renderFileSelector () { renderFileSelector () {
const { walletFile, walletFileError } = this.props.store; const { walletFile, walletFileError } = this.props.createStore;
return walletFile return walletFile
? ( ? (
@ -133,27 +133,27 @@ export default class NewImport extends Component {
} }
onFileSelect = (fileName, fileContent) => { onFileSelect = (fileName, fileContent) => {
const { store } = this.props; const { createStore } = this.props;
store.setWalletFile(fileName); createStore.setWalletFile(fileName);
store.setWalletJson(fileContent); createStore.setWalletJson(fileContent);
} }
onEditName = (event, name) => { onEditName = (event, name) => {
const { store } = this.props; const { createStore } = this.props;
store.setName(name); createStore.setName(name);
} }
onEditPassword = (event, password) => { onEditPassword = (event, password) => {
const { store } = this.props; const { createStore } = this.props;
store.setPassword(password); createStore.setPassword(password);
} }
onEditPasswordHint = (event, passwordHint) => { onEditPasswordHint = (event, passwordHint) => {
const { store } = this.props; const { createStore } = this.props;
store.setPasswordHint(passwordHint); createStore.setPasswordHint(passwordHint);
} }
} }

View File

@ -30,7 +30,7 @@ function render () {
store = createStore(); store = createStore();
component = shallow( component = shallow(
<NewImport <NewImport
store={ store } createStore={ store }
/> />
); );
instance = component.instance(); instance = component.instance();

View File

@ -0,0 +1,17 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default from './newQr';

View File

@ -0,0 +1,134 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { Form, Input, InputAddress, QrScan } from '~/ui';
import ChangeVault from '../ChangeVault';
export default class NewQr extends Component {
static propTypes = {
createStore: PropTypes.object.isRequired,
vaultStore: PropTypes.object.isRequired
};
render () {
const { createStore } = this.props;
return createStore.qrAddressValid
? this.renderInfo()
: this.renderScanner();
}
renderInfo () {
const { createStore, vaultStore } = this.props;
const { description, name, nameError, qrAddress } = createStore;
return (
<Form>
<InputAddress
allowCopy
readOnly
hint={
<FormattedMessage
id='createAccount.newQr.address.hint'
defaultMessage='the network address for the account'
/>
}
label={
<FormattedMessage
id='createAccount.newQr.address.label'
defaultMessage='address'
/>
}
value={ qrAddress }
/>
<Input
autoFocus
error={ nameError }
hint={
<FormattedMessage
id='createAccount.newQr.name.hint'
defaultMessage='a descriptive name for the account'
/>
}
label={
<FormattedMessage
id='createAccount.newQr.name.label'
defaultMessage='account name'
/>
}
onChange={ this.onEditAccountName }
value={ name }
/>
<Input
hint={
<FormattedMessage
id='createAccount.newQr.description.hint'
defaultMessage='a description for the account'
/>
}
label={
<FormattedMessage
id='createAccount.newQr.description.label'
defaultMessage='account description'
/>
}
onChange={ this.onEditAccountDescription }
value={ description }
/>
<ChangeVault
createStore={ createStore }
vaultStore={ vaultStore }
/>
</Form>
);
}
renderScanner () {
return (
<div>
<FormattedMessage
id='createAccount.newQr.summary'
defaultMessage='Use the built-in machine camera to scan to QR code of the account you wish to attach as an external account. External accounts are signed on the external device.'
/>
<QrScan onScan={ this.onScan } />
</div>
);
}
onEditAccountDescription = (event, description) => {
const { createStore } = this.props;
createStore.setDescription(description);
}
onEditAccountName = (event, name) => {
const { createStore } = this.props;
createStore.setName(name);
}
onScan = (address) => {
const { createStore } = this.props;
console.log('QR scan', address);
createStore.setQrAddress(address);
}
}

View File

@ -0,0 +1,93 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { shallow } from 'enzyme';
import React from 'react';
import sinon from 'sinon';
import NewQr from './';
let component;
let instance;
let createStore;
let vaultStore;
function createStores () {
createStore = {
qrAddressValid: false,
setDescription: sinon.stub(),
setName: sinon.stub(),
setQrAddress: sinon.stub()
};
vaultStore = {};
}
function render (props = {}) {
createStores();
component = shallow(
<NewQr
createStore={ createStore }
vaultStore={ vaultStore }
/>
);
instance = component.instance();
return component;
}
describe('modals/CreateAccount/NewQr', () => {
beforeEach(() => {
render();
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
describe('event methods', () => {
describe('onEditAccountDescription', () => {
beforeEach(() => {
instance.onEditAccountDescription(null, 'testing');
});
it('calls into createStore.setDescription', () => {
expect(createStore.setDescription).to.have.been.calledWith('testing');
});
});
describe('onEditAccountName', () => {
beforeEach(() => {
instance.onEditAccountName(null, 'testing');
});
it('calls into createStore.setName', () => {
expect(createStore.setName).to.have.been.calledWith('testing');
});
});
describe('onScan', () => {
beforeEach(() => {
instance.onScan('testing');
});
it('calls into createStore.setQrAddress', () => {
expect(createStore.setQrAddress).to.have.been.calledWith('testing');
});
});
});
});

View File

@ -31,12 +31,12 @@ export default class RawKey extends Component {
} }
static propTypes = { static propTypes = {
store: PropTypes.object.isRequired, createStore: PropTypes.object.isRequired,
vaultStore: PropTypes.object vaultStore: PropTypes.object
} }
render () { render () {
const { name, nameError, password, passwordRepeat, passwordRepeatError, passwordHint, rawKey, rawKeyError } = this.props.store; const { name, nameError, password, passwordRepeat, passwordRepeatError, passwordHint, rawKey, rawKeyError } = this.props.createStore;
return ( return (
<Form> <Form>
@ -134,7 +134,7 @@ export default class RawKey extends Component {
</div> </div>
<PasswordStrength input={ password } /> <PasswordStrength input={ password } />
<ChangeVault <ChangeVault
store={ this.props.store } createStore={ this.props.createStore }
vaultStore={ this.props.vaultStore } vaultStore={ this.props.vaultStore }
/> />
</Form> </Form>
@ -142,32 +142,32 @@ export default class RawKey extends Component {
} }
onEditName = (event, name) => { onEditName = (event, name) => {
const { store } = this.props; const { createStore } = this.props;
store.setName(name); createStore.setName(name);
} }
onEditPasswordHint = (event, passwordHint) => { onEditPasswordHint = (event, passwordHint) => {
const { store } = this.props; const { createStore } = this.props;
store.setPasswordHint(passwordHint); createStore.setPasswordHint(passwordHint);
} }
onEditPassword = (event, password) => { onEditPassword = (event, password) => {
const { store } = this.props; const { createStore } = this.props;
store.setPassword(password); createStore.setPassword(password);
} }
onEditPasswordRepeat = (event, password) => { onEditPasswordRepeat = (event, password) => {
const { store } = this.props; const { createStore } = this.props;
store.setPasswordRepeat(password); createStore.setPasswordRepeat(password);
} }
onEditKey = (event, rawKey) => { onEditKey = (event, rawKey) => {
const { store } = this.props; const { createStore } = this.props;
store.setRawKey(rawKey); createStore.setRawKey(rawKey);
} }
} }

View File

@ -30,7 +30,7 @@ function render () {
store = createStore(); store = createStore();
component = shallow( component = shallow(
<RawKey <RawKey
store={ store } createStore={ store }
/> />
); );
instance = component.instance(); instance = component.instance();

View File

@ -28,12 +28,12 @@ import styles from '../createAccount.css';
@observer @observer
export default class RecoveryPhrase extends Component { export default class RecoveryPhrase extends Component {
static propTypes = { static propTypes = {
store: PropTypes.object.isRequired, createStore: PropTypes.object.isRequired,
vaultStore: PropTypes.object vaultStore: PropTypes.object
} }
render () { render () {
const { isWindowsPhrase, name, nameError, password, passwordRepeat, passwordRepeatError, passwordHint, phrase } = this.props.store; const { isWindowsPhrase, name, nameError, password, passwordRepeat, passwordRepeatError, passwordHint, phrase } = this.props.createStore;
return ( return (
<Form> <Form>
@ -130,7 +130,7 @@ export default class RecoveryPhrase extends Component {
</div> </div>
<PasswordStrength input={ password } /> <PasswordStrength input={ password } />
<ChangeVault <ChangeVault
store={ this.props.store } createStore={ this.props.createStore }
vaultStore={ this.props.vaultStore } vaultStore={ this.props.vaultStore }
/> />
<Checkbox <Checkbox
@ -149,38 +149,38 @@ export default class RecoveryPhrase extends Component {
} }
onToggleWindowsPhrase = (event) => { onToggleWindowsPhrase = (event) => {
const { store } = this.props; const { createStore } = this.props;
store.setWindowsPhrase(!store.isWindowsPhrase); createStore.setWindowsPhrase(!createStore.isWindowsPhrase);
} }
onEditPhrase = (event, phrase) => { onEditPhrase = (event, phrase) => {
const { store } = this.props; const { createStore } = this.props;
store.setPhrase(phrase); createStore.setPhrase(phrase);
} }
onEditName = (event, name) => { onEditName = (event, name) => {
const { store } = this.props; const { createStore } = this.props;
store.setName(name); createStore.setName(name);
} }
onEditPassword = (event, password) => { onEditPassword = (event, password) => {
const { store } = this.props; const { createStore } = this.props;
store.setPassword(password); createStore.setPassword(password);
} }
onEditPasswordRepeat = (event, password) => { onEditPasswordRepeat = (event, password) => {
const { store } = this.props; const { createStore } = this.props;
store.setPasswordRepeat(password); createStore.setPasswordRepeat(password);
} }
onEditPasswordHint = (event, passwordHint) => { onEditPasswordHint = (event, passwordHint) => {
const { store } = this.props; const { createStore } = this.props;
store.setPasswordHint(passwordHint); createStore.setPasswordHint(passwordHint);
} }
} }

View File

@ -30,7 +30,7 @@ function render () {
store = createStore(); store = createStore();
component = shallow( component = shallow(
<RecoveryPhrase <RecoveryPhrase
store={ store } createStore={ store }
/> />
); );
instance = component.instance(); instance = component.instance();

View File

@ -16,20 +16,20 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { AccountsIcon, DoneIcon, FileIcon, FileUploadIcon, KeyboardIcon, KeyIcon, MembershipIcon } from '~/ui/Icons'; import { AccountsIcon, DoneIcon, FileIcon, FileUploadIcon, KeyboardIcon, KeyIcon, MembershipIcon, PhoneLock } from '~/ui/Icons';
import { STAGE_INFO } from '../store'; import { STAGE_INFO } from '../store';
export default class TypeIcon extends Component { export default class TypeIcon extends Component {
static propTypes = { static propTypes = {
className: PropTypes.string, className: PropTypes.string,
store: PropTypes.object.isRequired, createStore: PropTypes.object.isRequired,
type: PropTypes.string type: PropTypes.string
} }
render () { render () {
const { className, store, type } = this.props; const { className, createStore, type } = this.props;
const { createType, stage } = store; const { createType, stage } = createStore;
if (stage === STAGE_INFO) { if (stage === STAGE_INFO) {
return <DoneIcon className={ className } />; return <DoneIcon className={ className } />;
@ -39,18 +39,21 @@ export default class TypeIcon extends Component {
case 'fromGeth': case 'fromGeth':
return <FileUploadIcon className={ className } />; return <FileUploadIcon className={ className } />;
case 'fromPhrase':
return <KeyboardIcon className={ className } />;
case 'fromRaw':
return <KeyIcon className={ className } />;
case 'fromJSON': case 'fromJSON':
return <FileIcon className={ className } />; return <FileIcon className={ className } />;
case 'fromPhrase':
return <KeyboardIcon className={ className } />;
case 'fromPresale': case 'fromPresale':
return <MembershipIcon className={ className } />; return <MembershipIcon className={ className } />;
case 'fromQr':
return <PhoneLock className={ className } />;
case 'fromRaw':
return <KeyIcon className={ className } />;
case 'fromNew': case 'fromNew':
default: default:
return <AccountsIcon className={ className } />; return <AccountsIcon className={ className } />;

View File

@ -34,6 +34,7 @@ import CreationType from './CreationType';
import NewAccount from './NewAccount'; import NewAccount from './NewAccount';
import NewGeth from './NewGeth'; import NewGeth from './NewGeth';
import NewImport from './NewImport'; import NewImport from './NewImport';
import NewQr from './NewQr';
import RawKey from './RawKey'; import RawKey from './RawKey';
import RecoveryPhrase from './RecoveryPhrase'; import RecoveryPhrase from './RecoveryPhrase';
import Store, { STAGE_CREATE, STAGE_INFO, STAGE_SELECT_TYPE } from './store'; import Store, { STAGE_CREATE, STAGE_INFO, STAGE_SELECT_TYPE } from './store';
@ -65,10 +66,17 @@ const TITLES = {
id='createAccount.title.importWallet' id='createAccount.title.importWallet'
defaultMessage='import wallet' defaultMessage='import wallet'
/> />
),
qr: (
<FormattedMessage
id='createAccount.title.qr'
defaultMessage='external account'
/>
) )
}; };
const STAGE_NAMES = [TITLES.type, TITLES.create, TITLES.info]; const STAGE_NAMES = [TITLES.type, TITLES.create, TITLES.info];
const STAGE_IMPORT = [TITLES.type, TITLES.import, TITLES.info]; const STAGE_IMPORT = [TITLES.type, TITLES.import, TITLES.info];
const STAGE_QR = [TITLES.type, TITLES.qr, TITLES.info];
@observer @observer
class CreateAccount extends Component { class CreateAccount extends Component {
@ -83,7 +91,7 @@ class CreateAccount extends Component {
onUpdate: PropTypes.func onUpdate: PropTypes.func
} }
store = new Store(this.context.api, this.props.accounts); createStore = new Store(this.context.api, this.props.accounts);
vaultStore = VaultStore.get(this.context.api); vaultStore = VaultStore.get(this.context.api);
componentWillMount () { componentWillMount () {
@ -91,7 +99,15 @@ class CreateAccount extends Component {
} }
render () { render () {
const { isBusy, createType, stage } = this.store; const { isBusy, createType, stage } = this.createStore;
let steps = STAGE_IMPORT;
if (createType === 'fromNew') {
steps = STAGE_NAMES;
} else if (createType === 'fromQr') {
steps = STAGE_QR;
}
return ( return (
<Portal <Portal
@ -100,13 +116,9 @@ class CreateAccount extends Component {
activeStep={ stage } activeStep={ stage }
onClose={ this.onClose } onClose={ this.onClose }
open open
steps={ steps={ steps }
createType === 'fromNew'
? STAGE_NAMES
: STAGE_IMPORT
}
> >
<ModalBox icon={ <TypeIcon store={ this.store } /> }> <ModalBox icon={ <TypeIcon createStore={ this.createStore } /> }>
{ this.renderPage() } { this.renderPage() }
</ModalBox> </ModalBox>
</Portal> </Portal>
@ -114,12 +126,12 @@ class CreateAccount extends Component {
} }
renderPage () { renderPage () {
const { createType, stage } = this.store; const { createType, stage } = this.createStore;
switch (stage) { switch (stage) {
case STAGE_SELECT_TYPE: case STAGE_SELECT_TYPE:
return ( return (
<CreationType store={ this.store } /> <CreationType createStore={ this.createStore } />
); );
case STAGE_CREATE: case STAGE_CREATE:
@ -127,7 +139,7 @@ class CreateAccount extends Component {
return ( return (
<NewAccount <NewAccount
newError={ this.props.newError } newError={ this.props.newError }
store={ this.store } createStore={ this.createStore }
vaultStore={ this.vaultStore } vaultStore={ this.vaultStore }
/> />
); );
@ -135,14 +147,23 @@ class CreateAccount extends Component {
if (createType === 'fromGeth') { if (createType === 'fromGeth') {
return ( return (
<NewGeth store={ this.store } /> <NewGeth createStore={ this.createStore } />
); );
} }
if (createType === 'fromPhrase') { if (createType === 'fromPhrase') {
return ( return (
<RecoveryPhrase <RecoveryPhrase
store={ this.store } createStore={ this.createStore }
vaultStore={ this.vaultStore }
/>
);
}
if (createType === 'fromQr') {
return (
<NewQr
createStore={ this.createStore }
vaultStore={ this.vaultStore } vaultStore={ this.vaultStore }
/> />
); );
@ -151,7 +172,7 @@ class CreateAccount extends Component {
if (createType === 'fromRaw') { if (createType === 'fromRaw') {
return ( return (
<RawKey <RawKey
store={ this.store } createStore={ this.createStore }
vaultStore={ this.vaultStore } vaultStore={ this.vaultStore }
/> />
); );
@ -159,7 +180,7 @@ class CreateAccount extends Component {
return ( return (
<NewImport <NewImport
store={ this.store } createStore={ this.createStore }
vaultStore={ this.vaultStore } vaultStore={ this.vaultStore }
/> />
); );
@ -167,18 +188,18 @@ class CreateAccount extends Component {
case STAGE_INFO: case STAGE_INFO:
if (createType === 'fromGeth') { if (createType === 'fromGeth') {
return ( return (
<AccountDetailsGeth store={ this.store } /> <AccountDetailsGeth createStore={ this.createStore } />
); );
} }
return ( return (
<AccountDetails store={ this.store } /> <AccountDetails createStore={ this.createStore } />
); );
} }
} }
renderDialogActions () { renderDialogActions () {
const { createType, canCreate, isBusy, stage } = this.store; const { createType, canCreate, isBusy, stage } = this.createStore;
const cancelBtn = ( const cancelBtn = (
<Button <Button
@ -207,7 +228,7 @@ class CreateAccount extends Component {
defaultMessage='Next' defaultMessage='Next'
/> />
} }
onClick={ this.store.nextStage } onClick={ this.createStore.nextStage }
/> />
]; ];
@ -223,7 +244,7 @@ class CreateAccount extends Component {
defaultMessage='Back' defaultMessage='Back'
/> />
} }
onClick={ this.store.prevStage } onClick={ this.createStore.prevStage }
/>, />,
<Button <Button
disabled={ !canCreate || isBusy } disabled={ !canCreate || isBusy }
@ -281,17 +302,17 @@ class CreateAccount extends Component {
} }
onCreate = () => { onCreate = () => {
this.store.setBusy(true); this.createStore.setBusy(true);
return this.store return this.createStore
.createAccount(this.vaultStore) .createAccount(this.vaultStore)
.then(() => { .then(() => {
this.store.setBusy(false); this.createStore.setBusy(false);
this.store.nextStage(); this.createStore.nextStage();
this.props.onUpdate && this.props.onUpdate(); this.props.onUpdate && this.props.onUpdate();
}) })
.catch((error) => { .catch((error) => {
this.store.setBusy(false); this.createStore.setBusy(false);
this.props.newError(error); this.props.newError(error);
}); });
} }
@ -301,7 +322,7 @@ class CreateAccount extends Component {
} }
printPhrase = () => { printPhrase = () => {
const { address, name, phrase } = this.store; const { address, name, phrase } = this.createStore;
const identity = createIdentityImg(address); const identity = createIdentityImg(address);
print(recoveryPage({ print(recoveryPage({

View File

@ -41,6 +41,7 @@ export default class Store {
@observable passwordHint = ''; @observable passwordHint = '';
@observable passwordRepeat = ''; @observable passwordRepeat = '';
@observable phrase = ''; @observable phrase = '';
@observable qrAddress = null;
@observable rawKey = ''; @observable rawKey = '';
@observable rawKeyError = ERRORS.nokey; @observable rawKeyError = ERRORS.nokey;
@observable stage = STAGE_SELECT_TYPE; @observable stage = STAGE_SELECT_TYPE;
@ -73,6 +74,9 @@ export default class Store {
case 'fromPhrase': case 'fromPhrase':
return !(this.nameError || this.passwordRepeatError); return !(this.nameError || this.passwordRepeatError);
case 'fromQr':
return this.qrAddressValid && !this.nameError;
case 'fromRaw': case 'fromRaw':
return !(this.nameError || this.passwordRepeatError || this.rawKeyError); return !(this.nameError || this.passwordRepeatError || this.rawKeyError);
@ -87,13 +91,19 @@ export default class Store {
: ERRORS.noMatchPassword; : ERRORS.noMatchPassword;
} }
@computed get qrAddressValid () {
return this._api.util.isAddressValid(this.qrAddress);
}
@action clearErrors = () => { @action clearErrors = () => {
transaction(() => { transaction(() => {
this.description = '';
this.password = ''; this.password = '';
this.passwordRepeat = ''; this.passwordRepeat = '';
this.phrase = ''; this.phrase = '';
this.name = ''; this.name = '';
this.nameError = null; this.nameError = null;
this.qrAddress = null;
this.rawKey = ''; this.rawKey = '';
this.rawKeyError = null; this.rawKeyError = null;
this.vaultName = ''; this.vaultName = '';
@ -136,6 +146,14 @@ export default class Store {
this.gethImported = gethImported; this.gethImported = gethImported;
} }
@action setQrAddress = (qrAddress) => {
if (qrAddress && qrAddress.substr(0, 2) !== '0x') {
qrAddress = `0x${qrAddress}`;
}
this.qrAddress = qrAddress;
}
@action setVaultName = (vaultName) => { @action setVaultName = (vaultName) => {
this.vaultName = vaultName; this.vaultName = vaultName;
} }
@ -260,6 +278,9 @@ export default class Store {
case 'fromPhrase': case 'fromPhrase':
return this.createAccountFromPhrase(); return this.createAccountFromPhrase();
case 'fromQr':
return this.createAccountFromQr();
case 'fromRaw': case 'fromRaw':
return this.createAccountFromRaw(); return this.createAccountFromRaw();
@ -274,17 +295,13 @@ export default class Store {
.then((gethImported) => { .then((gethImported) => {
console.log('createAccountFromGeth', gethImported); console.log('createAccountFromGeth', gethImported);
this.setName('Geth Import');
this.setDescription('Imported from Geth keystore');
this.setGethImported(gethImported); this.setGethImported(gethImported);
return Promise return Promise.all(gethImported.map((address) => {
.all(gethImported.map((address) => { return this.setupMeta(address, timestamp);
return this._api.parity.setAccountName(address, 'Geth Import'); }));
}))
.then(() => {
return Promise.all(gethImported.map((address) => {
return this._api.parity.setAccountMeta(address, { timestamp });
}));
});
}) })
.catch((error) => { .catch((error) => {
console.error('createAccountFromGeth', error); console.error('createAccountFromGeth', error);
@ -307,12 +324,7 @@ export default class Store {
.then((address) => { .then((address) => {
this.setAddress(address); this.setAddress(address);
return this._api.parity return this.setupMeta(address, timestamp);
.setAccountName(address, this.name)
.then(() => this._api.parity.setAccountMeta(address, {
passwordHint: this.passwordHint,
timestamp
}));
}) })
.catch((error) => { .catch((error) => {
console.error('createAccount', error); console.error('createAccount', error);
@ -320,18 +332,19 @@ export default class Store {
}); });
} }
createAccountFromQr = (timestamp = Date.now()) => {
this.setAddress(this.qrAddress);
return this.setupMeta(this.qrAddress, timestamp, { external: true });
}
createAccountFromRaw = (timestamp = Date.now()) => { createAccountFromRaw = (timestamp = Date.now()) => {
return this._api.parity return this._api.parity
.newAccountFromSecret(this.rawKey, this.password) .newAccountFromSecret(this.rawKey, this.password)
.then((address) => { .then((address) => {
this.setAddress(address); this.setAddress(address);
return this._api.parity return this.setupMeta(address, timestamp);
.setAccountName(address, this.name)
.then(() => this._api.parity.setAccountMeta(address, {
passwordHint: this.passwordHint,
timestamp
}));
}) })
.catch((error) => { .catch((error) => {
console.error('createAccount', error); console.error('createAccount', error);
@ -345,12 +358,7 @@ export default class Store {
.then((address) => { .then((address) => {
this.setAddress(address); this.setAddress(address);
return this._api.parity return this.setupMeta(address, timestamp);
.setAccountName(address, this.name)
.then(() => this._api.parity.setAccountMeta(address, {
passwordHint: this.passwordHint,
timestamp
}));
}) })
.catch((error) => { .catch((error) => {
console.error('createAccount', error); console.error('createAccount', error);
@ -358,6 +366,18 @@ export default class Store {
}); });
} }
setupMeta = (address, timestamp = Date.now(), extra = {}) => {
const meta = Object.assign({}, extra, {
description: this.description,
passwordHint: this.passwordHint,
timestamp
});
return this._api.parity
.setAccountName(address, this.name)
.then(() => this._api.parity.setAccountMeta(address, meta));
}
createIdentities = () => { createIdentities = () => {
return Promise return Promise
.all([ .all([

View File

@ -79,8 +79,9 @@ describe('modals/CreateAccount/Store', () => {
beforeEach(() => { beforeEach(() => {
store.setName('testing'); store.setName('testing');
store.setPassword('testing'); store.setPassword('testing');
store.setVaultName('testing'); store.setQrAddress('testing');
store.setRawKey('test'); store.setRawKey('test');
store.setVaultName('testing');
store.setWalletFile('test'); store.setWalletFile('test');
store.setWalletJson('test'); store.setWalletJson('test');
}); });
@ -92,6 +93,7 @@ describe('modals/CreateAccount/Store', () => {
expect(store.nameError).to.be.null; expect(store.nameError).to.be.null;
expect(store.password).to.equal(''); expect(store.password).to.equal('');
expect(store.passwordRepeatError).to.be.null; expect(store.passwordRepeatError).to.be.null;
expect(store.qrAddress).to.be.null;
expect(store.rawKey).to.equal(''); expect(store.rawKey).to.equal('');
expect(store.rawKeyError).to.be.null; expect(store.rawKeyError).to.be.null;
expect(store.vaultName).to.equal(''); expect(store.vaultName).to.equal('');
@ -182,6 +184,20 @@ describe('modals/CreateAccount/Store', () => {
}); });
}); });
describe('setQrAddress', () => {
const ADDR = '0x1234567890123456789012345678901234567890';
it('sets the address', () => {
store.setQrAddress(ADDR);
expect(store.qrAddress).to.equal(ADDR);
});
it('adds leading 0x if not found', () => {
store.setQrAddress(ADDR.substr(2));
expect(store.qrAddress).to.equal(ADDR);
});
});
describe('setRawKey', () => { describe('setRawKey', () => {
it('sets error when empty key', () => { it('sets error when empty key', () => {
store.setRawKey(null); store.setRawKey(null);
@ -402,10 +418,34 @@ describe('modals/CreateAccount/Store', () => {
}); });
describe('operations', () => { describe('operations', () => {
describe('setupMeta', () => {
beforeEach(() => {
store.setDescription('test description');
store.setName('test name');
store.setPasswordHint('some hint');
return store.setupMeta('testaddr', 'timestamp', { something: 'else' });
});
it('sets the name for the acocunt', () => {
expect(api.parity.setAccountName).to.have.been.calledWith('testaddr', 'test name');
});
it('sets the meta for the account', () => {
expect(api.parity.setAccountMeta).to.have.been.calledWith('testaddr', {
description: 'test description',
passwordHint: 'some hint',
something: 'else',
timestamp: 'timestamp'
});
});
});
describe('createAccount', () => { describe('createAccount', () => {
let createAccountFromGethSpy; let createAccountFromGethSpy;
let createAccountFromWalletSpy; let createAccountFromWalletSpy;
let createAccountFromPhraseSpy; let createAccountFromPhraseSpy;
let createAccountFromQrSpy;
let createAccountFromRawSpy; let createAccountFromRawSpy;
let busySpy; let busySpy;
@ -413,6 +453,7 @@ describe('modals/CreateAccount/Store', () => {
createAccountFromGethSpy = sinon.spy(store, 'createAccountFromGeth'); createAccountFromGethSpy = sinon.spy(store, 'createAccountFromGeth');
createAccountFromWalletSpy = sinon.spy(store, 'createAccountFromWallet'); createAccountFromWalletSpy = sinon.spy(store, 'createAccountFromWallet');
createAccountFromPhraseSpy = sinon.spy(store, 'createAccountFromPhrase'); createAccountFromPhraseSpy = sinon.spy(store, 'createAccountFromPhrase');
createAccountFromQrSpy = sinon.spy(store, 'createAccountFromQr');
createAccountFromRawSpy = sinon.spy(store, 'createAccountFromRaw'); createAccountFromRawSpy = sinon.spy(store, 'createAccountFromRaw');
busySpy = sinon.spy(store, 'setBusy'); busySpy = sinon.spy(store, 'setBusy');
}); });
@ -422,6 +463,7 @@ describe('modals/CreateAccount/Store', () => {
store.createAccountFromWallet.restore(); store.createAccountFromWallet.restore();
store.createAccountFromPhrase.restore(); store.createAccountFromPhrase.restore();
store.createAccountFromRaw.restore(); store.createAccountFromRaw.restore();
store.createAccountFromQr.restore();
store.setBusy.restore(); store.setBusy.restore();
}); });
@ -470,6 +512,14 @@ describe('modals/CreateAccount/Store', () => {
}); });
}); });
it('calls createAccountFromQr on createType === fromQr', () => {
store.setCreateType('fromQr');
return store.createAccount().then(() => {
expect(createAccountFromQrSpy).to.have.been.called;
});
});
it('calls createAccountFromRaw on createType === fromRaw', () => { it('calls createAccountFromRaw on createType === fromRaw', () => {
store.setCreateType('fromRaw'); store.setCreateType('fromRaw');
@ -516,6 +566,8 @@ describe('modals/CreateAccount/Store', () => {
it('sets the account meta', () => { it('sets the account meta', () => {
return store.createAccountFromGeth(-1).then(() => { return store.createAccountFromGeth(-1).then(() => {
expect(store._api.parity.setAccountMeta).to.have.been.calledWith(GETH_ADDRESSES[0], { expect(store._api.parity.setAccountMeta).to.have.been.calledWith(GETH_ADDRESSES[0], {
description: 'Imported from Geth keystore',
passwordHint: '',
timestamp: -1 timestamp: -1
}); });
}); });
@ -552,6 +604,7 @@ describe('modals/CreateAccount/Store', () => {
it('sets the account meta', () => { it('sets the account meta', () => {
return store.createAccountFromPhrase(-1).then(() => { return store.createAccountFromPhrase(-1).then(() => {
expect(store._api.parity.setAccountMeta).to.have.been.calledWith(ADDRESS, { expect(store._api.parity.setAccountMeta).to.have.been.calledWith(ADDRESS, {
description: '',
passwordHint: 'some hint', passwordHint: 'some hint',
timestamp: -1 timestamp: -1
}); });
@ -574,6 +627,29 @@ describe('modals/CreateAccount/Store', () => {
}); });
}); });
describe('createAccountFromQr', () => {
beforeEach(() => {
store.setName('some name');
store.setDescription('some desc');
store.setQrAddress('0x123');
return store.createAccountFromQr(-1);
});
it('sets the accountInfo name', () => {
expect(api.parity.setAccountName).to.have.been.calledWith('0x123', 'some name');
});
it('sets the meta (with extrenal flag)', () => {
expect(api.parity.setAccountMeta).to.have.been.calledWith('0x123', {
description: 'some desc',
passwordHint: '',
timestamp: -1,
external: true
});
});
});
describe('createAccountFromRaw', () => { describe('createAccountFromRaw', () => {
beforeEach(() => { beforeEach(() => {
store.setName('some name'); store.setName('some name');
@ -603,6 +679,7 @@ describe('modals/CreateAccount/Store', () => {
it('sets the account meta', () => { it('sets the account meta', () => {
return store.createAccountFromRaw(-1).then(() => { return store.createAccountFromRaw(-1).then(() => {
expect(store._api.parity.setAccountMeta).to.have.been.calledWith(ADDRESS, { expect(store._api.parity.setAccountMeta).to.have.been.calledWith(ADDRESS, {
description: '',
passwordHint: 'some hint', passwordHint: 'some hint',
timestamp: -1 timestamp: -1
}); });
@ -639,6 +716,7 @@ describe('modals/CreateAccount/Store', () => {
it('sets the account meta', () => { it('sets the account meta', () => {
return store.createAccountFromWallet(-1).then(() => { return store.createAccountFromWallet(-1).then(() => {
expect(store._api.parity.setAccountMeta).to.have.been.calledWith(ADDRESS, { expect(store._api.parity.setAccountMeta).to.have.been.calledWith(ADDRESS, {
description: '',
passwordHint: 'some hint', passwordHint: 'some hint',
timestamp: -1 timestamp: -1
}); });

View File

@ -124,12 +124,12 @@ class FirstRun extends Component {
return ( return (
<NewAccount <NewAccount
newError={ this.props.newError } newError={ this.props.newError }
store={ this.createStore } createStore={ this.createStore }
/> />
); );
case 3: case 3:
return ( return (
<AccountDetails store={ this.createStore } /> <AccountDetails createStore={ this.createStore } />
); );
case 4: case 4:
return ( return (

View File

@ -48,6 +48,9 @@ export function personalAccountsInfo (accountsInfo) {
account.hardware = true; account.hardware = true;
hardware[account.address] = account; hardware[account.address] = account;
accounts[account.address] = account; accounts[account.address] = account;
} else if (account.meta.external) {
account.external = true;
accounts[account.address] = account;
} else { } else {
contacts[account.address] = account; contacts[account.address] = account;
} }

View File

@ -18,6 +18,7 @@ import * as actions from './signerActions';
import { inHex } from '~/api/format/input'; import { inHex } from '~/api/format/input';
import HardwareStore from '~/mobx/hardwareStore'; import HardwareStore from '~/mobx/hardwareStore';
import { createSignedTx } from '~/util/qrscan';
import { Signer } from '~/util/signer'; import { Signer } from '~/util/signer';
export default class SignerMiddleware { export default class SignerMiddleware {
@ -86,14 +87,25 @@ export default class SignerMiddleware {
return this._hwstore.signLedger(transaction); return this._hwstore.signLedger(transaction);
}) })
.then((rawTx) => { .then((rawTx) => {
const handlePromise = this._createConfirmPromiseHandler(store, id); return this.confirmRawTransaction(store, id, rawTx);
return handlePromise(this._api.signer.confirmRequestRaw(id, rawTx));
}); });
} }
confirmWalletTransaction (store, id, transaction, wallet, password) { confirmRawTransaction (store, id, rawTx) {
const handlePromise = this._createConfirmPromiseHandler(store, id); const handlePromise = this._createConfirmPromiseHandler(store, id);
return handlePromise(this._api.signer.confirmRequestRaw(id, rawTx));
}
confirmSignedTransaction (store, id, txSigned) {
const { netVersion } = store.getState().nodeStatus;
const { signature, tx } = txSigned;
const { rlp } = createSignedTx(netVersion, signature, tx);
return this.confirmRawTransaction(store, id, rlp);
}
confirmWalletTransaction (store, id, transaction, wallet, password) {
const { worker } = store.getState().worker; const { worker } = store.getState().worker;
const signerPromise = worker && worker._worker.state === 'activated' const signerPromise = worker && worker._worker.state === 'activated'
@ -126,7 +138,7 @@ export default class SignerMiddleware {
return signer.signTransaction(txData); return signer.signTransaction(txData);
}) })
.then((rawTx) => { .then((rawTx) => {
return handlePromise(this._api.signer.confirmRequestRaw(id, rawTx)); return this.confirmRawTransaction(store, id, rawTx);
}) })
.catch((error) => { .catch((error) => {
console.error(error.message); console.error(error.message);
@ -135,7 +147,7 @@ export default class SignerMiddleware {
} }
onConfirmStart = (store, action) => { onConfirmStart = (store, action) => {
const { condition, gas = 0, gasPrice = 0, id, password, payload, wallet } = action.payload; const { condition, gas = 0, gasPrice = 0, id, password, payload, txSigned, wallet } = action.payload;
const handlePromise = this._createConfirmPromiseHandler(store, id); const handlePromise = this._createConfirmPromiseHandler(store, id);
const transaction = payload.sendTransaction || payload.signTransaction; const transaction = payload.sendTransaction || payload.signTransaction;
@ -144,6 +156,8 @@ export default class SignerMiddleware {
if (wallet) { if (wallet) {
return this.confirmWalletTransaction(store, id, transaction, wallet, password); return this.confirmWalletTransaction(store, id, transaction, wallet, password);
} else if (txSigned) {
return this.confirmSignedTransaction(store, id, txSigned);
} else if (hardwareAccount) { } else if (hardwareAccount) {
switch (hardwareAccount.via) { switch (hardwareAccount.via) {
case 'ledger': case 'ledger':

View File

@ -74,6 +74,18 @@ export default class GasPriceEditor {
} }
} }
@computed get conditionValue () {
switch (this.conditionType) {
case CONDITIONS.BLOCK:
return { block: new BigNumber(this.condition.block || 0) };
case CONDITIONS.TIME:
return { time: this.condition.time };
}
return;
}
@action setConditionType = (conditionType = CONDITIONS.NONE) => { @action setConditionType = (conditionType = CONDITIONS.NONE) => {
transaction(() => { transaction(() => {
this.conditionBlockError = null; this.conditionBlockError = null;
@ -224,11 +236,8 @@ export default class GasPriceEditor {
switch (this.conditionType) { switch (this.conditionType) {
case CONDITIONS.BLOCK: case CONDITIONS.BLOCK:
result.condition = { block: new BigNumber(this.condition.block || 0) };
break;
case CONDITIONS.TIME: case CONDITIONS.TIME:
result.condition = { time: this.condition.time }; result.condition = this.conditionValue;
break; break;
case CONDITIONS.NONE: case CONDITIONS.NONE:

View File

@ -49,6 +49,7 @@ export MembershipIcon from 'material-ui/svg-icons/action/card-membership';
export MoveIcon from 'material-ui/svg-icons/action/open-with'; export MoveIcon from 'material-ui/svg-icons/action/open-with';
export NextIcon from 'material-ui/svg-icons/navigation/arrow-forward'; export NextIcon from 'material-ui/svg-icons/navigation/arrow-forward';
export PauseIcon from 'material-ui/svg-icons/av/pause'; export PauseIcon from 'material-ui/svg-icons/av/pause';
export PhoneLock from 'material-ui/svg-icons/communication/phonelink-lock';
export PlayIcon from 'material-ui/svg-icons/av/play-arrow'; export PlayIcon from 'material-ui/svg-icons/av/play-arrow';
export PrevIcon from 'material-ui/svg-icons/navigation/arrow-back'; export PrevIcon from 'material-ui/svg-icons/navigation/arrow-back';
export PrintIcon from 'material-ui/svg-icons/action/print'; export PrintIcon from 'material-ui/svg-icons/action/print';

View File

@ -0,0 +1,32 @@
/* Copyright 2015-2017 Parity Technologies (UK) Ltd.
/* This file is part of Parity.
/*
/* Parity is free software: you can redistribute it and/or modify
/* it under the terms of the GNU General Public License as published by
/* the Free Software Foundation, either version 3 of the License, or
/* (at your option) any later version.
/*
/* Parity is distributed in the hope that it will be useful,
/* but WITHOUT ANY WARRANTY; without even the implied warranty of
/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/* GNU General Public License for more details.
/*
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.qr {
max-width: 15em;
max-height: 15em;
img {
height: auto !important;
max-height: 100%;
max-width: 100%;
width: auto !important;
}
svg {
background: white;
}
}

View File

@ -14,14 +14,17 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>. // along with Parity. If not, see <http://www.gnu.org/licenses/>.
// https://github.com/cmanzana/qrcode-npm packaging the standard import qrcode from 'qrcode-generator/js/qrcode';
// https://github.com/kazuhikoarase/qrcode-generator
import { qrcode } from 'qrcode-npm';
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { calculateType } from './qrSize';
import styles from './qrCode.css';
const QROPTS = { const QROPTS = {
CODE_TYPE: 4, ERROR_LEVEL: 'M',
ERROR_LEVEL: 'M' MAX_SIZE: 40,
MIN_SIZE: 5
}; };
export default class QrCode extends Component { export default class QrCode extends Component {
@ -61,7 +64,7 @@ export default class QrCode extends Component {
return ( return (
<div <div
className={ className } className={ [styles.qr, className].join(' ') }
dangerouslySetInnerHTML={ { dangerouslySetInnerHTML={ {
__html: image __html: image
} } } }
@ -71,9 +74,10 @@ export default class QrCode extends Component {
generateCode (props) { generateCode (props) {
const { margin, size, value } = props; const { margin, size, value } = props;
const qr = qrcode(QROPTS.CODE_TYPE, QROPTS.ERROR_LEVEL); const type = calculateType(value.length, QROPTS.ERROR_LEVEL);
const qr = qrcode(type, QROPTS.ERROR_LEVEL);
qr.addData(value); qr.addData(value, 'Byte');
qr.make(); qr.make();
this.setState({ this.setState({

View File

@ -0,0 +1,77 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
const _QR_SIZES = { 'L': [], 'M': [], 'H': [], 'Q': [] };
const QR_LEVELS = Object.keys(_QR_SIZES);
/* eslint-disable indent,no-multi-spaces */
const QR_SIZES = [
19, 16, 13, 9,
34, 28, 22, 16,
55, 44, 34, 26,
80, 64, 48, 36,
108, 86, 62, 46,
136, 108, 76, 60,
156, 124, 88, 66,
194, 154, 110, 86,
232, 182, 132, 100,
274, 216, 154, 122,
324, 254, 180, 140,
370, 290, 206, 158,
428, 334, 244, 180,
461, 365, 261, 197,
523, 415, 295, 223,
589, 453, 325, 253,
647, 507, 367, 283,
721, 563, 397, 313,
795, 627, 445, 341,
861, 669, 485, 385,
932, 714, 512, 406,
1006, 782, 568, 442,
1094, 860, 614, 464,
1174, 914, 664, 514,
1276, 1000, 718, 538,
1370, 1062, 754, 596,
1468, 1128, 808, 628,
1531, 1193, 871, 661,
1631, 1267, 911, 701,
1735, 1373, 985, 745,
1843, 1455, 1033, 793,
1955, 1541, 1115, 845,
2071, 1631, 1171, 901,
2191, 1725, 1231, 961,
2306, 1812, 1286, 986,
2434, 1914, 1354, 1054,
2566, 1992, 1426, 1096,
2702, 2102, 1502, 1142,
2812, 2216, 1582, 1222,
2956, 2334, 1666, 1276
].reduce((sizes, value, index) => {
sizes[QR_LEVELS[index % 4]].push(value);
return sizes;
}, _QR_SIZES);
/* eslint-enable indent,no-multi-spaces */
export function calculateType (lengthBytes, errorLevel = 'M') {
let type = 5;
while (type < 40 && lengthBytes > QR_SIZES[errorLevel][type - 1]) {
type++;
}
return type;
}

17
js/src/ui/QrScan/index.js Normal file
View File

@ -0,0 +1,17 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default from './qrScan';

View File

@ -0,0 +1,25 @@
/* Copyright 2015-2017 Parity Technologies (UK) Ltd.
/* This file is part of Parity.
/*
/* Parity is free software: you can redistribute it and/or modify
/* it under the terms of the GNU General Public License as published by
/* the Free Software Foundation, either version 3 of the License, or
/* (at your option) any later version.
/*
/* Parity is distributed in the hope that it will be useful,
/* but WITHOUT ANY WARRANTY; without even the implied warranty of
/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/* GNU General Public License for more details.
/*
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.qr {
text-align: center;
video {
border: 2px solid rgb(200, 200, 200);
margin: 1.5em auto;
}
}

View File

@ -0,0 +1,50 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { PropTypes } from 'react';
import Reader from 'react-qr-reader';
import styles from './qrScan.css';
const SCAN_DELAY = 100;
const SCAN_STYLE = {
display: 'inline-block',
width: '30em'
};
export default function QrScan ({ onError, onScan }) {
return (
<div className={ styles.qr }>
<Reader
delay={ SCAN_DELAY }
onError={ onError }
onScan={ onScan }
style={ SCAN_STYLE }
/>
</div>
);
}
QrScan.propTypes = {
onError: PropTypes.func,
onScan: PropTypes.func.isRequired
};
QrScan.defaultProps = {
onError: (error) => {
console.log('QrScan', error);
}
};

View File

@ -0,0 +1,69 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { shallow } from 'enzyme';
import React from 'react';
import sinon from 'sinon';
import QrScan from './';
let component;
let onError;
let onScan;
function render () {
onError = sinon.stub();
onScan = sinon.stub();
component = shallow(
<QrScan
onError={ onError }
onScan={ onScan }
/>
);
return component;
}
describe('ui/QrScan', () => {
beforeEach(() => {
render();
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
describe('Reader', () => {
let reader;
beforeEach(() => {
reader = component.find('Reader');
});
it('renders component', () => {
expect(reader.get(0)).to.be.ok;
});
it('attaches onError', () => {
expect(reader.props().onError).to.equal(onError);
});
it('attaches onScan', () => {
expect(reader.props().onScan).to.equal(onScan);
});
});
});

View File

@ -46,6 +46,7 @@ export Page from './Page';
export ParityBackground from './ParityBackground'; export ParityBackground from './ParityBackground';
export Portal from './Portal'; export Portal from './Portal';
export QrCode from './QrCode'; export QrCode from './QrCode';
export QrScan from './QrScan';
export ScrollableText from './ScrollableText'; export ScrollableText from './ScrollableText';
export SectionList from './SectionList'; export SectionList from './SectionList';
export SelectionList from './SelectionList'; export SelectionList from './SelectionList';

121
js/src/util/qrscan.js Normal file
View File

@ -0,0 +1,121 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import Transaction from 'ethereumjs-tx';
import { inAddress, inHex, inNumber10 } from '~/api/format/input';
import { sha3 } from '~/api/util/sha3';
export function createUnsignedTx (api, netVersion, gasStore, transaction) {
const { data, from, gas, gasPrice, to, value } = gasStore.overrideTransaction(transaction);
return api.parity
.nextNonce(from)
.then((_nonce) => {
const chainId = parseInt(netVersion, 10);
const nonce = (!transaction.nonce || transaction.nonce.isZero())
? _nonce
: transaction.nonce;
const tx = new Transaction({
chainId,
data: inHex(data),
gasPrice: inHex(gasPrice),
gasLimit: inHex(gas),
nonce: inHex(nonce),
to: to ? inHex(to) : undefined,
value: inHex(value),
r: 0,
s: 0,
v: chainId
});
const rlp = inHex(tx.serialize().toString('hex'));
const hash = sha3(rlp);
return {
chainId,
hash,
nonce,
rlp,
tx
};
});
}
export function createSignedTx (netVersion, signature, unsignedTx) {
const chainId = parseInt(netVersion, 10);
const { data, gasPrice, gasLimit, nonce, to, value } = unsignedTx;
const r = Buffer.from(signature.substr(2, 64), 'hex');
const s = Buffer.from(signature.substr(66, 64), 'hex');
const v = Buffer.from([parseInt(signature.substr(130, 2), 16) + (chainId * 2) + 35]);
const tx = new Transaction({
chainId,
data,
gasPrice,
gasLimit,
nonce,
to,
value,
r,
s,
v
});
return {
chainId,
rlp: inHex(tx.serialize().toString('hex')),
tx
};
}
export function generateQr (from, tx, hash, rlp) {
if (tx.data && tx.data.length > 64) {
return JSON.stringify({
action: 'signTransactionHash',
data: {
account: from.substr(2),
hash: hash.substr(2),
details: {
gasPrice: inNumber10(inHex(tx.gasPrice.toString('hex'))),
gas: inNumber10(inHex(tx.gasLimit.toString('hex'))),
nonce: inNumber10(inHex(tx.nonce.toString('hex'))),
to: inAddress(tx.to.toString('hex')),
value: inHex(tx.value.toString('hex') || '0')
}
}
});
}
return JSON.stringify({
action: 'signTransaction',
data: {
account: from.substr(2),
rlp: rlp.substr(2)
}
});
}
export function generateTxQr (api, netVersion, gasStore, transaction) {
return createUnsignedTx(api, netVersion, gasStore, transaction)
.then((qr) => {
qr.value = generateQr(transaction.from, qr.tx, qr.hash, qr.rlp);
return qr;
});
}

View File

@ -94,10 +94,15 @@ export class Signer {
this.seed = seed; this.seed = seed;
} }
signTransactionObject (tx) {
tx.sign(this.seed);
return tx;
}
signTransaction (transaction) { signTransaction (transaction) {
const tx = new Transaction(transaction); const tx = new Transaction(transaction);
tx.sign(this.seed); return inHex(this.signTransactionObject(tx).serialize().toString('hex'));
return inHex(tx.serialize().toString('hex'));
} }
} }

View File

@ -220,7 +220,7 @@ class Account extends Component {
} }
onClick={ this.store.toggleEditDialog } onClick={ this.store.toggleEditDialog }
/>, />,
!account.hardware && ( !(account.external || account.hardware) && (
<Button <Button
icon={ <LockedIcon /> } icon={ <LockedIcon /> }
key='passwordManager' key='passwordManager'
@ -237,10 +237,19 @@ class Account extends Component {
icon={ <DeleteIcon /> } icon={ <DeleteIcon /> }
key='delete' key='delete'
label={ label={
<FormattedMessage account.external || account.hardware
id='account.button.delete' ? (
defaultMessage='delete' <FormattedMessage
/> id='account.button.forget'
defaultMessage='forget'
/>
)
: (
<FormattedMessage
id='account.button.delete'
defaultMessage='delete'
/>
)
} }
onClick={ this.store.toggleDeleteDialog } onClick={ this.store.toggleDeleteDialog }
/> />
@ -281,6 +290,23 @@ class Account extends Component {
); );
} }
if (account.external) {
return (
<DeleteAddress
account={ account }
confirmMessage={
<FormattedMessage
id='account.external.confirmDelete'
defaultMessage='Are you sure you want to remove the following external address from your account list?'
/>
}
visible
route='/accounts'
onClose={ this.store.toggleDeleteDialog }
/>
);
}
return ( return (
<DeleteAccount <DeleteAccount
account={ account } account={ account }

View File

@ -110,7 +110,7 @@ class Accounts extends Component {
} }
/> />
{ this.renderHwWallets() } { this.renderExternalAccounts() }
{ this.renderWallets() } { this.renderWallets() }
{ this.renderAccounts() } { this.renderAccounts() }
</Page> </Page>
@ -182,13 +182,14 @@ class Accounts extends Component {
); );
} }
renderHwWallets () { renderExternalAccounts () {
const { accounts, balances } = this.props; const { accounts, balances } = this.props;
const { wallets } = this.hwstore; const { wallets } = this.hwstore;
const hardware = pickBy(accounts, (account) => account.hardware); const hardware = pickBy(accounts, (account) => account.hardware);
const hasHardware = Object.keys(hardware).length > 0; const external = pickBy(accounts, (account) => account.external);
const all = Object.assign({}, hardware, external);
if (!hasHardware) { if (Object.keys(all).length === 0) {
return null; return null;
} }
@ -208,7 +209,7 @@ class Accounts extends Component {
return ( return (
<List <List
search={ searchValues } search={ searchValues }
accounts={ hardware } accounts={ all }
balances={ balances } balances={ balances }
disabled={ disabled } disabled={ disabled }
order={ sortOrder } order={ sortOrder }

View File

@ -80,10 +80,11 @@ export default class AccountStore {
.keys(allAccounts) .keys(allAccounts)
.filter((address) => { .filter((address) => {
const account = allAccounts[address]; const account = allAccounts[address];
const isAccount = account.uuid || (account.meta && account.meta.hardware); const isAccount = account.uuid;
const isExternal = account.meta && (account.meta.external || account.meta.hardware);
const isWhitelisted = !whitelist || whitelist.includes(address); const isWhitelisted = !whitelist || whitelist.includes(address);
return isAccount && isWhitelisted; return (isAccount || isExternal) && isWhitelisted;
}) })
.map((address) => { .map((address) => {
return { return {

View File

@ -127,9 +127,12 @@ class TransactionPending extends Component {
address={ from } address={ from }
disabled={ disabled } disabled={ disabled }
focus={ focus } focus={ focus }
gasStore={ this.gasStore }
isSending={ isSending } isSending={ isSending }
netVersion={ netVersion }
onConfirm={ this.onConfirm } onConfirm={ this.onConfirm }
onReject={ this.onReject } onReject={ this.onReject }
transaction={ transaction }
/> />
</div> </div>
); );
@ -157,7 +160,7 @@ class TransactionPending extends Component {
onConfirm = (data) => { onConfirm = (data) => {
const { id, transaction } = this.props; const { id, transaction } = this.props;
const { password, wallet } = data; const { password, txSigned, wallet } = data;
const { condition, gas, gasPrice } = this.gasStore.overrideTransaction(transaction); const { condition, gas, gasPrice } = this.gasStore.overrideTransaction(transaction);
const options = { const options = {
@ -165,6 +168,7 @@ class TransactionPending extends Component {
gasPrice, gasPrice,
id, id,
password, password,
txSigned,
wallet wallet
}; };

View File

@ -44,3 +44,8 @@
.fileInput input { .fileInput input {
top: 22px; top: 22px;
} }
.qr {
margin-bottom: 0.5em;
text-align: center;
}

View File

@ -21,18 +21,30 @@ import ReactDOM from 'react-dom';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ReactTooltip from 'react-tooltip'; import ReactTooltip from 'react-tooltip';
import { Form, Input, IdentityIcon } from '~/ui'; import { Form, Input, IdentityIcon, QrCode, QrScan } from '~/ui';
import { generateTxQr } from '~/util/qrscan';
import styles from './transactionPendingFormConfirm.css'; import styles from './transactionPendingFormConfirm.css';
const QR_VISIBLE = 1;
const QR_SCAN = 2;
const QR_COMPLETED = 3;
export default class TransactionPendingFormConfirm extends Component { export default class TransactionPendingFormConfirm extends Component {
static contextTypes = {
api: PropTypes.object.isRequired
};
static propTypes = { static propTypes = {
account: PropTypes.object, account: PropTypes.object,
address: PropTypes.string.isRequired, address: PropTypes.string.isRequired,
disabled: PropTypes.bool, disabled: PropTypes.bool,
focus: PropTypes.bool,
gasStore: PropTypes.object.isRequired,
netVersion: PropTypes.string.isRequired,
isSending: PropTypes.bool.isRequired, isSending: PropTypes.bool.isRequired,
onConfirm: PropTypes.func.isRequired, onConfirm: PropTypes.func.isRequired,
focus: PropTypes.bool transaction: PropTypes.object.isRequired
}; };
static defaultProps = { static defaultProps = {
@ -44,6 +56,8 @@ export default class TransactionPendingFormConfirm extends Component {
state = { state = {
password: '', password: '',
qrState: QR_VISIBLE,
qr: {},
wallet: null, wallet: null,
walletError: null walletError: null
} }
@ -52,6 +66,15 @@ export default class TransactionPendingFormConfirm extends Component {
this.focus(); this.focus();
} }
componentWillMount () {
this.readNonce();
this.subscribeNonce();
}
componentWillUnmount () {
this.unsubscribeNonce();
}
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
if (!this.props.focus && nextProps.focus) { if (!this.props.focus && nextProps.focus) {
this.focus(nextProps); this.focus(nextProps);
@ -93,64 +116,96 @@ export default class TransactionPendingFormConfirm extends Component {
return walletHint || null; return walletHint || null;
} }
// TODO: Now that we have 3 types, it would make sense splitting each into their own
// sub-module and having the consistent bits combined (e.g. i18n, layouts)
render () { render () {
const { account, address, disabled, isSending } = this.props; const { account, address, disabled, isSending } = this.props;
const { wallet, walletError } = this.state; const { wallet, walletError } = this.state;
const isWalletOk = account.hardware || account.uuid || (walletError === null && wallet !== null); const isAccount = account.external || account.hardware || account.uuid;
const isWalletOk = isAccount || (walletError === null && wallet !== null);
const confirmText = this.renderConfirmButton();
const confirmButton = confirmText
? (
<div
data-effect='solid'
data-for={ `transactionConfirmForm${this.id}` }
data-place='bottom'
data-tip
>
<RaisedButton
className={ styles.confirmButton }
disabled={ disabled || isSending || !isWalletOk }
fullWidth
icon={
<IdentityIcon
address={ address }
button
className={ styles.signerIcon }
/>
}
label={ confirmText }
onTouchTap={ this.onConfirm }
primary
/>
</div>
)
: null;
return ( return (
<div className={ styles.confirmForm }> <div className={ styles.confirmForm }>
<Form> <Form>
{ this.renderKeyInput() } { this.renderKeyInput() }
{ this.renderQrCode() }
{ this.renderQrScanner() }
{ this.renderPassword() } { this.renderPassword() }
{ this.renderHint() } { this.renderHint() }
<div { confirmButton }
data-effect='solid'
data-for={ `transactionConfirmForm${this.id}` }
data-place='bottom'
data-tip
>
<RaisedButton
className={ styles.confirmButton }
disabled={ disabled || isSending || !isWalletOk }
fullWidth
icon={
<IdentityIcon
address={ address }
button
className={ styles.signerIcon }
/>
}
label={
isSending
? (
<FormattedMessage
id='signer.txPendingConfirm.buttons.confirmBusy'
defaultMessage='Confirming...'
/>
)
: (
<FormattedMessage
id='signer.txPendingConfirm.buttons.confirmRequest'
defaultMessage='Confirm Request'
/>
)
}
onTouchTap={ this.onConfirm }
primary
/>
</div>
{ this.renderTooltip() } { this.renderTooltip() }
</Form> </Form>
</div> </div>
); );
} }
renderConfirmButton () {
const { account, isSending } = this.props;
const { qrState } = this.state;
if (account.external) {
switch (qrState) {
case QR_VISIBLE:
return (
<FormattedMessage
id='signer.txPendingConfirm.buttons.scanSigned'
defaultMessage='Scan Signed QR'
/>
);
case QR_SCAN:
case QR_COMPLETED:
return null;
}
}
return isSending
? (
<FormattedMessage
id='signer.txPendingConfirm.buttons.confirmBusy'
defaultMessage='Confirming...'
/>
)
: (
<FormattedMessage
id='signer.txPendingConfirm.buttons.confirmRequest'
defaultMessage='Confirm Request'
/>
);
}
renderPassword () { renderPassword () {
const { account } = this.props; const { account } = this.props;
const { password } = this.state; const { password } = this.state;
if (account.hardware) { if (account.hardware || account.external) {
return null; return null;
} }
@ -199,6 +254,34 @@ export default class TransactionPendingFormConfirm extends Component {
renderHint () { renderHint () {
const { account, disabled, isSending } = this.props; const { account, disabled, isSending } = this.props;
const { qrState } = this.state;
if (account.external) {
switch (qrState) {
case QR_VISIBLE:
return (
<div className={ styles.passwordHint }>
<FormattedMessage
id='signer.sending.external.scanTx'
defaultMessage='Please scan the transaction QR on your external device'
/>
</div>
);
case QR_SCAN:
return (
<div className={ styles.passwordHint }>
<FormattedMessage
id='signer.sending.external.scanSigned'
defaultMessage='Scan the QR code of the signed transaction from your external device'
/>
</div>
);
case QR_COMPLETED:
return null;
}
}
if (account.hardware) { if (account.hardware) {
if (isSending) { if (isSending) {
@ -241,11 +324,45 @@ export default class TransactionPendingFormConfirm extends Component {
); );
} }
// TODO: Split into sub-scomponent
renderQrCode () {
const { account } = this.props;
const { qrState, qr } = this.state;
if (!account.external || qrState !== QR_VISIBLE || !qr.value) {
return null;
}
return (
<QrCode
className={ styles.qr }
value={ qr.value }
/>
);
}
// TODO: Split into sub-scomponent
renderQrScanner () {
const { account } = this.props;
const { qrState } = this.state;
if (!account.external || qrState !== QR_SCAN) {
return null;
}
return (
<QrScan
className={ styles.camera }
onScan={ this.onScanTx }
/>
);
}
renderKeyInput () { renderKeyInput () {
const { account } = this.props; const { account } = this.props;
const { walletError } = this.state; const { walletError } = this.state;
if (account.uuid || account.wallet || account.hardware) { if (account.uuid || account.wallet || account.hardware || account.external) {
return null; return null;
} }
@ -274,8 +391,8 @@ export default class TransactionPendingFormConfirm extends Component {
renderTooltip () { renderTooltip () {
const { account } = this.props; const { account } = this.props;
if (this.state.password.length || account.hardware) { if (this.state.password.length || account.hardware || account.external) {
return; return null;
} }
return ( return (
@ -288,6 +405,25 @@ export default class TransactionPendingFormConfirm extends Component {
); );
} }
onScanTx = (signature) => {
const { chainId, rlp, tx } = this.state.qr;
if (signature && signature.substr(0, 2) !== '0x') {
signature = `0x${signature}`;
}
this.setState({ qrState: QR_COMPLETED });
this.props.onConfirm({
txSigned: {
chainId,
rlp,
signature,
tx
}
});
}
onKeySelect = (event) => { onKeySelect = (event) => {
// Check that file have been selected // Check that file have been selected
if (event.target.files.length === 0) { if (event.target.files.length === 0) {
@ -338,7 +474,12 @@ export default class TransactionPendingFormConfirm extends Component {
} }
onConfirm = () => { onConfirm = () => {
const { password, wallet } = this.state; const { account } = this.props;
const { password, qrState, wallet } = this.state;
if (account.external && qrState === QR_VISIBLE) {
return this.setState({ qrState: QR_SCAN });
}
this.props.onConfirm({ this.props.onConfirm({
password, password,
@ -346,6 +487,15 @@ export default class TransactionPendingFormConfirm extends Component {
}); });
} }
generateTxQr = () => {
const { api } = this.context;
const { netVersion, gasStore, transaction } = this.props;
generateTxQr(api, netVersion, gasStore, transaction).then((qr) => {
this.setState({ qr });
});
}
onKeyDown = (event) => { onKeyDown = (event) => {
const codeName = keycode(event); const codeName = keycode(event);
@ -355,4 +505,43 @@ export default class TransactionPendingFormConfirm extends Component {
this.onConfirm(); this.onConfirm();
} }
// FIXME: Sadly the API subscription channels currently does not allow for specific values,
// rather it can only do general queries where parameters are not specified. Hence we are
// polling for the nonce here. Since we are moving to node-based subscriptions on the API layer,
// this can be optimised when the subscription mechanism is reworked to conform.
subscribeNonce () {
const nonceTimerId = setInterval(this.readNonce, 1000);
this.setState({ nonceTimerId });
}
unsubscribeNonce () {
const { nonceTimerId } = this.state;
if (!nonceTimerId) {
return;
}
clearInterval(nonceTimerId);
}
readNonce = () => {
const { api } = this.context;
const { account } = this.props;
if (!account || !account.external || !api.transport.isConnected) {
return;
}
return api.parity
.nextNonce(account.address)
.then((nonce) => {
const { qr } = this.state;
if (!qr.nonce || !nonce.eq(qr.nonce)) {
this.generateTxQr();
}
});
}
} }

View File

@ -27,12 +27,15 @@ export default class TransactionPendingForm extends Component {
static propTypes = { static propTypes = {
account: PropTypes.object, account: PropTypes.object,
address: PropTypes.string.isRequired, address: PropTypes.string.isRequired,
className: PropTypes.string,
disabled: PropTypes.bool, disabled: PropTypes.bool,
focus: PropTypes.bool,
gasStore: PropTypes.object.isRequired,
netVersion: PropTypes.string.isRequired,
isSending: PropTypes.bool.isRequired, isSending: PropTypes.bool.isRequired,
onConfirm: PropTypes.func.isRequired, onConfirm: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired, onReject: PropTypes.func.isRequired,
className: PropTypes.string, transaction: PropTypes.object.isRequired
focus: PropTypes.bool
}; };
static defaultProps = { static defaultProps = {
@ -56,7 +59,7 @@ export default class TransactionPendingForm extends Component {
} }
renderForm () { renderForm () {
const { account, address, disabled, focus, isSending, onConfirm, onReject } = this.props; const { account, address, disabled, focus, gasStore, isSending, netVersion, onConfirm, onReject, transaction } = this.props;
if (this.state.isRejectOpen) { if (this.state.isRejectOpen) {
return ( return (
@ -70,8 +73,11 @@ export default class TransactionPendingForm extends Component {
account={ account } account={ account }
disabled={ disabled } disabled={ disabled }
focus={ focus } focus={ focus }
gasStore={ gasStore }
netVersion={ netVersion }
isSending={ isSending } isSending={ isSending }
onConfirm={ onConfirm } onConfirm={ onConfirm }
transaction={ transaction }
/> />
); );
} }

View File

@ -44,6 +44,7 @@ global.document = jsdom.jsdom('<!doctype html><html><body></body></html>');
global.window = document.defaultView; global.window = document.defaultView;
global.navigator = global.window.navigator; global.navigator = global.window.navigator;
global.location = global.window.location; global.location = global.window.location;
global.Blob = () => {};
// attach mocked localStorage onto the window as exposed by jsdom // attach mocked localStorage onto the window as exposed by jsdom
global.window.localStorage = global.localStorage; global.window.localStorage = global.localStorage;