diff --git a/js/.npmrc b/js/.npmrc index a00908d4f..5dce03c65 100644 --- a/js/.npmrc +++ b/js/.npmrc @@ -1 +1,2 @@ save-prefix='' +unsafe-perm=true diff --git a/js/package.json b/js/package.json index b03fd8e6f..62803cc70 100644 --- a/js/package.json +++ b/js/package.json @@ -26,6 +26,7 @@ "Promise" ], "scripts": { + "install": "napa", "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: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)", "prepush": "npm run lint:cached" }, + "napa": { + "qrcode-generator": "kazuhikoarase/qrcode-generator" + }, "devDependencies": { "babel-cli": "6.23.0", "babel-core": "6.23.1", @@ -182,10 +186,10 @@ "mobx-react": "4.0.3", "mobx-react-devtools": "4.2.10", "moment": "2.17.0", + "napa": "2.3.0", "phoneformat.js": "1.0.3", "promise-worker": "1.1.1", "push.js": "0.0.11", - "qrcode-npm": "0.0.3", "qs": "6.3.0", "react": "15.4.2", "react-ace": "4.1.0", @@ -198,6 +202,7 @@ "react-intl": "2.1.5", "react-markdown": "2.4.4", "react-portal": "3.0.0", + "react-qr-reader": "1.0.3", "react-redux": "4.4.6", "react-router": "3.0.0", "react-router-redux": "4.0.7", diff --git a/js/src/api/rpc/eth/eth.js b/js/src/api/rpc/eth/eth.js index 824486ade..ffde938ea 100644 --- a/js/src/api/rpc/eth/eth.js +++ b/js/src/api/rpc/eth/eth.js @@ -288,9 +288,9 @@ export default class Eth { .execute('eth_sign', inAddress(address), inHash(hash)); } - signTransaction () { + signTransaction (options) { return this._transport - .execute('eth_signTransaction'); + .execute('eth_signTransaction', inOptions(options)); } submitHashrate (hashrate, clientId) { diff --git a/js/src/modals/CreateAccount/AccountDetails/accountDetails.js b/js/src/modals/CreateAccount/AccountDetails/accountDetails.js index 9e5b08b38..75a2ca9c4 100644 --- a/js/src/modals/CreateAccount/AccountDetails/accountDetails.js +++ b/js/src/modals/CreateAccount/AccountDetails/accountDetails.js @@ -25,11 +25,11 @@ import styles from '../createAccount.css'; @observer export default class AccountDetails extends Component { static propTypes = { - store: PropTypes.object.isRequired + createStore: PropTypes.object.isRequired } render () { - const { address, description, name } = this.props.store; + const { address, description, name } = this.props.createStore; return (
@@ -79,7 +79,7 @@ export default class AccountDetails extends Component { } renderPhrase () { - const { phrase } = this.props.store; + const { phrase } = this.props.createStore; if (!phrase) { return null; diff --git a/js/src/modals/CreateAccount/AccountDetails/accountDetails.spec.js b/js/src/modals/CreateAccount/AccountDetails/accountDetails.spec.js index 02b755952..99e1837cb 100644 --- a/js/src/modals/CreateAccount/AccountDetails/accountDetails.spec.js +++ b/js/src/modals/CreateAccount/AccountDetails/accountDetails.spec.js @@ -28,7 +28,7 @@ function render () { store = createStore(); component = shallow( ); diff --git a/js/src/modals/CreateAccount/AccountDetailsGeth/accountDetailsGeth.js b/js/src/modals/CreateAccount/AccountDetailsGeth/accountDetailsGeth.js index 7e2e89f3d..37a872e0e 100644 --- a/js/src/modals/CreateAccount/AccountDetailsGeth/accountDetailsGeth.js +++ b/js/src/modals/CreateAccount/AccountDetailsGeth/accountDetailsGeth.js @@ -26,11 +26,11 @@ import styles from '../createAccount.css'; @observer export default class AccountDetailsGeth extends Component { static propTypes = { - store: PropTypes.object.isRequired + createStore: PropTypes.object.isRequired } render () { - const { gethAccountsAvailable, gethImported } = this.props.store; + const { gethAccountsAvailable, gethImported } = this.props.createStore; const accounts = gethAccountsAvailable.filter((account) => gethImported.includes(account.address)); diff --git a/js/src/modals/CreateAccount/AccountDetailsGeth/accountDetailsGeth.spec.js b/js/src/modals/CreateAccount/AccountDetailsGeth/accountDetailsGeth.spec.js index b5813e9fa..6099202cf 100644 --- a/js/src/modals/CreateAccount/AccountDetailsGeth/accountDetailsGeth.spec.js +++ b/js/src/modals/CreateAccount/AccountDetailsGeth/accountDetailsGeth.spec.js @@ -28,7 +28,7 @@ function render () { store = createStore(); component = shallow( ); diff --git a/js/src/modals/CreateAccount/ChangeVault/changeVault.js b/js/src/modals/CreateAccount/ChangeVault/changeVault.js index 566fa402c..510251cb2 100644 --- a/js/src/modals/CreateAccount/ChangeVault/changeVault.js +++ b/js/src/modals/CreateAccount/ChangeVault/changeVault.js @@ -22,13 +22,13 @@ import { VaultSelect } from '~/ui'; @observer export default class ChangeVault extends Component { static propTypes = { - store: PropTypes.object.isRequired, + createStore: PropTypes.object.isRequired, vaultStore: PropTypes.object } render () { - const { store, vaultStore } = this.props; - const { vaultName } = store; + const { createStore, vaultStore } = this.props; + const { vaultName } = createStore; if (!vaultStore || vaultStore.vaultsOpened.length === 0) { return null; @@ -44,8 +44,8 @@ export default class ChangeVault extends Component { } onSelect = (vaultName) => { - const { store } = this.props; + const { createStore } = this.props; - store.setVaultName(vaultName); + createStore.setVaultName(vaultName); } } diff --git a/js/src/modals/CreateAccount/ChangeVault/changeVault.spec.js b/js/src/modals/CreateAccount/ChangeVault/changeVault.spec.js index a2fcb834b..789facf8e 100644 --- a/js/src/modals/CreateAccount/ChangeVault/changeVault.spec.js +++ b/js/src/modals/CreateAccount/ChangeVault/changeVault.spec.js @@ -45,7 +45,7 @@ function createVaultStore () { function render () { component = shallow( ); diff --git a/js/src/modals/CreateAccount/CreationType/creationType.js b/js/src/modals/CreateAccount/CreationType/creationType.js index a7d23a667..8869e6e63 100644 --- a/js/src/modals/CreateAccount/CreationType/creationType.js +++ b/js/src/modals/CreateAccount/CreationType/creationType.js @@ -54,6 +54,21 @@ const TYPES = [ ), key: 'fromPhrase' }, + { + description: ( + + ), + label: ( + + ), + key: 'fromQr' + }, { description: ( - ), - label: ( - - ), - key: 'fromPresale' - }, { description: ( ), key: 'fromRaw' + }, + { + description: ( + + ), + label: ( + + ), + key: 'fromPresale' } ]; @observer export default class CreationType extends Component { static propTypes = { - store: PropTypes.object.isRequired + createStore: PropTypes.object.isRequired } render () { - const { createType } = this.props.store; + const { createType } = this.props.createStore; return (
@@ -157,7 +172,7 @@ export default class CreationType extends Component {
{ - const { createType } = this.props.store; + const { createType } = this.props.createStore; return item.key === createType; } onChange = (item) => { - const { store } = this.props; + const { createStore } = this.props; - store.setCreateType(item.key); + createStore.setCreateType(item.key); } onSelect = (item) => { - const { store } = this.props; + const { createStore } = this.props; - store.setCreateType(item.key); - store.nextStage(); + createStore.setCreateType(item.key); + createStore.nextStage(); } } diff --git a/js/src/modals/CreateAccount/CreationType/creationType.spec.js b/js/src/modals/CreateAccount/CreationType/creationType.spec.js index 6b3f739c7..ddeaecae4 100644 --- a/js/src/modals/CreateAccount/CreationType/creationType.spec.js +++ b/js/src/modals/CreateAccount/CreationType/creationType.spec.js @@ -29,7 +29,7 @@ function render () { store = createStore(); component = shallow( <CreationType - store={ store } + createStore={ store } /> ); instance = component.instance(); diff --git a/js/src/modals/CreateAccount/NewAccount/newAccount.js b/js/src/modals/CreateAccount/NewAccount/newAccount.js index e0a2f8ba2..04f2f272a 100644 --- a/js/src/modals/CreateAccount/NewAccount/newAccount.js +++ b/js/src/modals/CreateAccount/NewAccount/newAccount.js @@ -31,7 +31,7 @@ import styles from '../createAccount.css'; export default class CreateAccount extends Component { static propTypes = { newError: PropTypes.func.isRequired, - store: PropTypes.object.isRequired, + createStore: PropTypes.object.isRequired, vaultStore: PropTypes.object } @@ -45,7 +45,7 @@ export default class CreateAccount extends Component { } render () { - const { name, nameError, password, passwordRepeat, passwordRepeatError, passwordHint } = this.props.store; + const { name, nameError, password, passwordRepeat, passwordRepeatError, passwordHint } = this.props.createStore; return ( <Form> @@ -126,7 +126,7 @@ export default class CreateAccount extends Component { </div> <PasswordStrength input={ password } /> <ChangeVault - store={ this.props.store } + createStore={ this.props.createStore } vaultStore={ this.props.vaultStore } /> { this.renderIdentitySelector() } @@ -203,16 +203,16 @@ export default class CreateAccount extends Component { } createIdentities = () => { - const { store } = this.props; + const { createStore } = this.props; - return store + return createStore .createIdentities() .then((accounts) => { const selectedAddress = Object.keys(accounts)[0]; const { phrase } = accounts[selectedAddress]; - store.setAddress(selectedAddress); - store.setPhrase(phrase); + createStore.setAddress(selectedAddress); + createStore.setPhrase(phrase); this.setState({ accounts, @@ -225,7 +225,7 @@ export default class CreateAccount extends Component { } onChangeIdentity = (event) => { - const { store } = this.props; + const { createStore } = this.props; const selectedAddress = event.target.value || event.target.getAttribute('value'); if (!selectedAddress) { @@ -235,32 +235,32 @@ export default class CreateAccount extends Component { this.setState({ selectedAddress }, () => { const { phrase } = this.state.accounts[selectedAddress]; - store.setAddress(selectedAddress); - store.setPhrase(phrase); + createStore.setAddress(selectedAddress); + createStore.setPhrase(phrase); }); } onEditPasswordHint = (event, passwordHint) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPasswordHint(passwordHint); + createStore.setPasswordHint(passwordHint); } onEditAccountName = (event, name) => { - const { store } = this.props; + const { createStore } = this.props; - store.setName(name); + createStore.setName(name); } onEditPassword = (event, password) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPassword(password); + createStore.setPassword(password); } onEditPasswordRepeat = (event, password) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPasswordRepeat(password); + createStore.setPasswordRepeat(password); } } diff --git a/js/src/modals/CreateAccount/NewAccount/newAccount.spec.js b/js/src/modals/CreateAccount/NewAccount/newAccount.spec.js index 246467e47..87c7ba3fc 100644 --- a/js/src/modals/CreateAccount/NewAccount/newAccount.spec.js +++ b/js/src/modals/CreateAccount/NewAccount/newAccount.spec.js @@ -32,7 +32,7 @@ function render () { store = createStore(); component = shallow( <NewAccount - store={ store } + createStore={ store } />, { context: { api } diff --git a/js/src/modals/CreateAccount/NewGeth/newGeth.js b/js/src/modals/CreateAccount/NewGeth/newGeth.js index 26f11d6a7..54c0acb50 100644 --- a/js/src/modals/CreateAccount/NewGeth/newGeth.js +++ b/js/src/modals/CreateAccount/NewGeth/newGeth.js @@ -30,11 +30,11 @@ export default class NewGeth extends Component { } static propTypes = { - store: PropTypes.object.isRequired + createStore: PropTypes.object.isRequired } render () { - const { gethAccountsAvailable, gethAddresses } = this.props.store; + const { gethAccountsAvailable, gethAddresses } = this.props.createStore; return gethAccountsAvailable.length ? ( @@ -84,14 +84,14 @@ export default class NewGeth extends Component { } isSelected = (account) => { - const { gethAddresses } = this.props.store; + const { gethAddresses } = this.props.createStore; return gethAddresses.includes(account.address); } onSelect = (account) => { - const { store } = this.props; + const { createStore } = this.props; - store.selectGethAccount(account.address); + createStore.selectGethAccount(account.address); } } diff --git a/js/src/modals/CreateAccount/NewGeth/newGeth.spec.js b/js/src/modals/CreateAccount/NewGeth/newGeth.spec.js index 6acc8be16..b25eb7d01 100644 --- a/js/src/modals/CreateAccount/NewGeth/newGeth.spec.js +++ b/js/src/modals/CreateAccount/NewGeth/newGeth.spec.js @@ -30,7 +30,7 @@ function render () { store = createStore(); component = shallow( <NewGeth - store={ store } + createStore={ store } /> ); instance = component.instance(); diff --git a/js/src/modals/CreateAccount/NewImport/newImport.js b/js/src/modals/CreateAccount/NewImport/newImport.js index e3d888c3f..f683e7c5d 100644 --- a/js/src/modals/CreateAccount/NewImport/newImport.js +++ b/js/src/modals/CreateAccount/NewImport/newImport.js @@ -26,13 +26,13 @@ import styles from '../createAccount.css'; @observer export default class NewImport extends Component { static propTypes = { - store: PropTypes.object.isRequired, + createStore: PropTypes.object.isRequired, vaultStore: PropTypes.object } render () { - const { name, nameError, password, passwordHint } = this.props.store; + const { name, nameError, password, passwordHint } = this.props.createStore; return ( <Form> @@ -92,7 +92,7 @@ export default class NewImport extends Component { </div> </div> <ChangeVault - store={ this.props.store } + createStore={ this.props.createStore } vaultStore={ this.props.vaultStore } /> { this.renderFileSelector() } @@ -101,7 +101,7 @@ export default class NewImport extends Component { } renderFileSelector () { - const { walletFile, walletFileError } = this.props.store; + const { walletFile, walletFileError } = this.props.createStore; return walletFile ? ( @@ -133,27 +133,27 @@ export default class NewImport extends Component { } onFileSelect = (fileName, fileContent) => { - const { store } = this.props; + const { createStore } = this.props; - store.setWalletFile(fileName); - store.setWalletJson(fileContent); + createStore.setWalletFile(fileName); + createStore.setWalletJson(fileContent); } onEditName = (event, name) => { - const { store } = this.props; + const { createStore } = this.props; - store.setName(name); + createStore.setName(name); } onEditPassword = (event, password) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPassword(password); + createStore.setPassword(password); } onEditPasswordHint = (event, passwordHint) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPasswordHint(passwordHint); + createStore.setPasswordHint(passwordHint); } } diff --git a/js/src/modals/CreateAccount/NewImport/newImport.spec.js b/js/src/modals/CreateAccount/NewImport/newImport.spec.js index 469cef4dd..ecd73e3b4 100644 --- a/js/src/modals/CreateAccount/NewImport/newImport.spec.js +++ b/js/src/modals/CreateAccount/NewImport/newImport.spec.js @@ -30,7 +30,7 @@ function render () { store = createStore(); component = shallow( <NewImport - store={ store } + createStore={ store } /> ); instance = component.instance(); diff --git a/js/src/modals/CreateAccount/NewQr/index.js b/js/src/modals/CreateAccount/NewQr/index.js new file mode 100644 index 000000000..e9eeee1e4 --- /dev/null +++ b/js/src/modals/CreateAccount/NewQr/index.js @@ -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'; diff --git a/js/src/modals/CreateAccount/NewQr/newQr.js b/js/src/modals/CreateAccount/NewQr/newQr.js new file mode 100644 index 000000000..362fd4d61 --- /dev/null +++ b/js/src/modals/CreateAccount/NewQr/newQr.js @@ -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); + } +} diff --git a/js/src/modals/CreateAccount/NewQr/newQr.spec.js b/js/src/modals/CreateAccount/NewQr/newQr.spec.js new file mode 100644 index 000000000..6fb567ad7 --- /dev/null +++ b/js/src/modals/CreateAccount/NewQr/newQr.spec.js @@ -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'); + }); + }); + }); +}); diff --git a/js/src/modals/CreateAccount/RawKey/rawKey.js b/js/src/modals/CreateAccount/RawKey/rawKey.js index 7f31cb066..a2121eb19 100644 --- a/js/src/modals/CreateAccount/RawKey/rawKey.js +++ b/js/src/modals/CreateAccount/RawKey/rawKey.js @@ -31,12 +31,12 @@ export default class RawKey extends Component { } static propTypes = { - store: PropTypes.object.isRequired, + createStore: PropTypes.object.isRequired, vaultStore: PropTypes.object } 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 ( <Form> @@ -134,7 +134,7 @@ export default class RawKey extends Component { </div> <PasswordStrength input={ password } /> <ChangeVault - store={ this.props.store } + createStore={ this.props.createStore } vaultStore={ this.props.vaultStore } /> </Form> @@ -142,32 +142,32 @@ export default class RawKey extends Component { } onEditName = (event, name) => { - const { store } = this.props; + const { createStore } = this.props; - store.setName(name); + createStore.setName(name); } onEditPasswordHint = (event, passwordHint) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPasswordHint(passwordHint); + createStore.setPasswordHint(passwordHint); } onEditPassword = (event, password) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPassword(password); + createStore.setPassword(password); } onEditPasswordRepeat = (event, password) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPasswordRepeat(password); + createStore.setPasswordRepeat(password); } onEditKey = (event, rawKey) => { - const { store } = this.props; + const { createStore } = this.props; - store.setRawKey(rawKey); + createStore.setRawKey(rawKey); } } diff --git a/js/src/modals/CreateAccount/RawKey/rawKey.spec.js b/js/src/modals/CreateAccount/RawKey/rawKey.spec.js index 02493d642..121336d78 100644 --- a/js/src/modals/CreateAccount/RawKey/rawKey.spec.js +++ b/js/src/modals/CreateAccount/RawKey/rawKey.spec.js @@ -30,7 +30,7 @@ function render () { store = createStore(); component = shallow( <RawKey - store={ store } + createStore={ store } /> ); instance = component.instance(); diff --git a/js/src/modals/CreateAccount/RecoveryPhrase/recoveryPhrase.js b/js/src/modals/CreateAccount/RecoveryPhrase/recoveryPhrase.js index 1e49f821f..0ecc65547 100644 --- a/js/src/modals/CreateAccount/RecoveryPhrase/recoveryPhrase.js +++ b/js/src/modals/CreateAccount/RecoveryPhrase/recoveryPhrase.js @@ -28,12 +28,12 @@ import styles from '../createAccount.css'; @observer export default class RecoveryPhrase extends Component { static propTypes = { - store: PropTypes.object.isRequired, + createStore: PropTypes.object.isRequired, vaultStore: PropTypes.object } 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 ( <Form> @@ -130,7 +130,7 @@ export default class RecoveryPhrase extends Component { </div> <PasswordStrength input={ password } /> <ChangeVault - store={ this.props.store } + createStore={ this.props.createStore } vaultStore={ this.props.vaultStore } /> <Checkbox @@ -149,38 +149,38 @@ export default class RecoveryPhrase extends Component { } onToggleWindowsPhrase = (event) => { - const { store } = this.props; + const { createStore } = this.props; - store.setWindowsPhrase(!store.isWindowsPhrase); + createStore.setWindowsPhrase(!createStore.isWindowsPhrase); } onEditPhrase = (event, phrase) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPhrase(phrase); + createStore.setPhrase(phrase); } onEditName = (event, name) => { - const { store } = this.props; + const { createStore } = this.props; - store.setName(name); + createStore.setName(name); } onEditPassword = (event, password) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPassword(password); + createStore.setPassword(password); } onEditPasswordRepeat = (event, password) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPasswordRepeat(password); + createStore.setPasswordRepeat(password); } onEditPasswordHint = (event, passwordHint) => { - const { store } = this.props; + const { createStore } = this.props; - store.setPasswordHint(passwordHint); + createStore.setPasswordHint(passwordHint); } } diff --git a/js/src/modals/CreateAccount/RecoveryPhrase/recoveryPhrase.spec.js b/js/src/modals/CreateAccount/RecoveryPhrase/recoveryPhrase.spec.js index d48740fdc..d6b5d3e70 100644 --- a/js/src/modals/CreateAccount/RecoveryPhrase/recoveryPhrase.spec.js +++ b/js/src/modals/CreateAccount/RecoveryPhrase/recoveryPhrase.spec.js @@ -30,7 +30,7 @@ function render () { store = createStore(); component = shallow( <RecoveryPhrase - store={ store } + createStore={ store } /> ); instance = component.instance(); diff --git a/js/src/modals/CreateAccount/TypeIcon/typeIcon.js b/js/src/modals/CreateAccount/TypeIcon/typeIcon.js index a19e17b96..e63763df7 100644 --- a/js/src/modals/CreateAccount/TypeIcon/typeIcon.js +++ b/js/src/modals/CreateAccount/TypeIcon/typeIcon.js @@ -16,20 +16,20 @@ 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'; export default class TypeIcon extends Component { static propTypes = { className: PropTypes.string, - store: PropTypes.object.isRequired, + createStore: PropTypes.object.isRequired, type: PropTypes.string } render () { - const { className, store, type } = this.props; - const { createType, stage } = store; + const { className, createStore, type } = this.props; + const { createType, stage } = createStore; if (stage === STAGE_INFO) { return <DoneIcon className={ className } />; @@ -39,18 +39,21 @@ export default class TypeIcon extends Component { case 'fromGeth': return <FileUploadIcon className={ className } />; - case 'fromPhrase': - return <KeyboardIcon className={ className } />; - - case 'fromRaw': - return <KeyIcon className={ className } />; - case 'fromJSON': return <FileIcon className={ className } />; + case 'fromPhrase': + return <KeyboardIcon className={ className } />; + case 'fromPresale': return <MembershipIcon className={ className } />; + case 'fromQr': + return <PhoneLock className={ className } />; + + case 'fromRaw': + return <KeyIcon className={ className } />; + case 'fromNew': default: return <AccountsIcon className={ className } />; diff --git a/js/src/modals/CreateAccount/createAccount.js b/js/src/modals/CreateAccount/createAccount.js index d89c2afa8..56f1db751 100644 --- a/js/src/modals/CreateAccount/createAccount.js +++ b/js/src/modals/CreateAccount/createAccount.js @@ -34,6 +34,7 @@ import CreationType from './CreationType'; import NewAccount from './NewAccount'; import NewGeth from './NewGeth'; import NewImport from './NewImport'; +import NewQr from './NewQr'; import RawKey from './RawKey'; import RecoveryPhrase from './RecoveryPhrase'; import Store, { STAGE_CREATE, STAGE_INFO, STAGE_SELECT_TYPE } from './store'; @@ -65,10 +66,17 @@ const TITLES = { id='createAccount.title.importWallet' defaultMessage='import wallet' /> + ), + qr: ( + <FormattedMessage + id='createAccount.title.qr' + defaultMessage='external account' + /> ) }; const STAGE_NAMES = [TITLES.type, TITLES.create, TITLES.info]; const STAGE_IMPORT = [TITLES.type, TITLES.import, TITLES.info]; +const STAGE_QR = [TITLES.type, TITLES.qr, TITLES.info]; @observer class CreateAccount extends Component { @@ -83,7 +91,7 @@ class CreateAccount extends Component { 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); componentWillMount () { @@ -91,7 +99,15 @@ class CreateAccount extends Component { } 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 ( <Portal @@ -100,13 +116,9 @@ class CreateAccount extends Component { activeStep={ stage } onClose={ this.onClose } open - steps={ - createType === 'fromNew' - ? STAGE_NAMES - : STAGE_IMPORT - } + steps={ steps } > - <ModalBox icon={ <TypeIcon store={ this.store } /> }> + <ModalBox icon={ <TypeIcon createStore={ this.createStore } /> }> { this.renderPage() } </ModalBox> </Portal> @@ -114,12 +126,12 @@ class CreateAccount extends Component { } renderPage () { - const { createType, stage } = this.store; + const { createType, stage } = this.createStore; switch (stage) { case STAGE_SELECT_TYPE: return ( - <CreationType store={ this.store } /> + <CreationType createStore={ this.createStore } /> ); case STAGE_CREATE: @@ -127,7 +139,7 @@ class CreateAccount extends Component { return ( <NewAccount newError={ this.props.newError } - store={ this.store } + createStore={ this.createStore } vaultStore={ this.vaultStore } /> ); @@ -135,14 +147,23 @@ class CreateAccount extends Component { if (createType === 'fromGeth') { return ( - <NewGeth store={ this.store } /> + <NewGeth createStore={ this.createStore } /> ); } if (createType === 'fromPhrase') { return ( <RecoveryPhrase - store={ this.store } + createStore={ this.createStore } + vaultStore={ this.vaultStore } + /> + ); + } + + if (createType === 'fromQr') { + return ( + <NewQr + createStore={ this.createStore } vaultStore={ this.vaultStore } /> ); @@ -151,7 +172,7 @@ class CreateAccount extends Component { if (createType === 'fromRaw') { return ( <RawKey - store={ this.store } + createStore={ this.createStore } vaultStore={ this.vaultStore } /> ); @@ -159,7 +180,7 @@ class CreateAccount extends Component { return ( <NewImport - store={ this.store } + createStore={ this.createStore } vaultStore={ this.vaultStore } /> ); @@ -167,18 +188,18 @@ class CreateAccount extends Component { case STAGE_INFO: if (createType === 'fromGeth') { return ( - <AccountDetailsGeth store={ this.store } /> + <AccountDetailsGeth createStore={ this.createStore } /> ); } return ( - <AccountDetails store={ this.store } /> + <AccountDetails createStore={ this.createStore } /> ); } } renderDialogActions () { - const { createType, canCreate, isBusy, stage } = this.store; + const { createType, canCreate, isBusy, stage } = this.createStore; const cancelBtn = ( <Button @@ -207,7 +228,7 @@ class CreateAccount extends Component { defaultMessage='Next' /> } - onClick={ this.store.nextStage } + onClick={ this.createStore.nextStage } /> ]; @@ -223,7 +244,7 @@ class CreateAccount extends Component { defaultMessage='Back' /> } - onClick={ this.store.prevStage } + onClick={ this.createStore.prevStage } />, <Button disabled={ !canCreate || isBusy } @@ -281,17 +302,17 @@ class CreateAccount extends Component { } onCreate = () => { - this.store.setBusy(true); + this.createStore.setBusy(true); - return this.store + return this.createStore .createAccount(this.vaultStore) .then(() => { - this.store.setBusy(false); - this.store.nextStage(); + this.createStore.setBusy(false); + this.createStore.nextStage(); this.props.onUpdate && this.props.onUpdate(); }) .catch((error) => { - this.store.setBusy(false); + this.createStore.setBusy(false); this.props.newError(error); }); } @@ -301,7 +322,7 @@ class CreateAccount extends Component { } printPhrase = () => { - const { address, name, phrase } = this.store; + const { address, name, phrase } = this.createStore; const identity = createIdentityImg(address); print(recoveryPage({ diff --git a/js/src/modals/CreateAccount/store.js b/js/src/modals/CreateAccount/store.js index c76102d5b..52dddac80 100644 --- a/js/src/modals/CreateAccount/store.js +++ b/js/src/modals/CreateAccount/store.js @@ -41,6 +41,7 @@ export default class Store { @observable passwordHint = ''; @observable passwordRepeat = ''; @observable phrase = ''; + @observable qrAddress = null; @observable rawKey = ''; @observable rawKeyError = ERRORS.nokey; @observable stage = STAGE_SELECT_TYPE; @@ -73,6 +74,9 @@ export default class Store { case 'fromPhrase': return !(this.nameError || this.passwordRepeatError); + case 'fromQr': + return this.qrAddressValid && !this.nameError; + case 'fromRaw': return !(this.nameError || this.passwordRepeatError || this.rawKeyError); @@ -87,13 +91,19 @@ export default class Store { : ERRORS.noMatchPassword; } + @computed get qrAddressValid () { + return this._api.util.isAddressValid(this.qrAddress); + } + @action clearErrors = () => { transaction(() => { + this.description = ''; this.password = ''; this.passwordRepeat = ''; this.phrase = ''; this.name = ''; this.nameError = null; + this.qrAddress = null; this.rawKey = ''; this.rawKeyError = null; this.vaultName = ''; @@ -136,6 +146,14 @@ export default class Store { this.gethImported = gethImported; } + @action setQrAddress = (qrAddress) => { + if (qrAddress && qrAddress.substr(0, 2) !== '0x') { + qrAddress = `0x${qrAddress}`; + } + + this.qrAddress = qrAddress; + } + @action setVaultName = (vaultName) => { this.vaultName = vaultName; } @@ -260,6 +278,9 @@ export default class Store { case 'fromPhrase': return this.createAccountFromPhrase(); + case 'fromQr': + return this.createAccountFromQr(); + case 'fromRaw': return this.createAccountFromRaw(); @@ -274,17 +295,13 @@ export default class Store { .then((gethImported) => { console.log('createAccountFromGeth', gethImported); + this.setName('Geth Import'); + this.setDescription('Imported from Geth keystore'); this.setGethImported(gethImported); - return Promise - .all(gethImported.map((address) => { - return this._api.parity.setAccountName(address, 'Geth Import'); - })) - .then(() => { - return Promise.all(gethImported.map((address) => { - return this._api.parity.setAccountMeta(address, { timestamp }); - })); - }); + return Promise.all(gethImported.map((address) => { + return this.setupMeta(address, timestamp); + })); }) .catch((error) => { console.error('createAccountFromGeth', error); @@ -307,12 +324,7 @@ export default class Store { .then((address) => { this.setAddress(address); - return this._api.parity - .setAccountName(address, this.name) - .then(() => this._api.parity.setAccountMeta(address, { - passwordHint: this.passwordHint, - timestamp - })); + return this.setupMeta(address, timestamp); }) .catch((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()) => { return this._api.parity .newAccountFromSecret(this.rawKey, this.password) .then((address) => { this.setAddress(address); - return this._api.parity - .setAccountName(address, this.name) - .then(() => this._api.parity.setAccountMeta(address, { - passwordHint: this.passwordHint, - timestamp - })); + return this.setupMeta(address, timestamp); }) .catch((error) => { console.error('createAccount', error); @@ -345,12 +358,7 @@ export default class Store { .then((address) => { this.setAddress(address); - return this._api.parity - .setAccountName(address, this.name) - .then(() => this._api.parity.setAccountMeta(address, { - passwordHint: this.passwordHint, - timestamp - })); + return this.setupMeta(address, timestamp); }) .catch((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 = () => { return Promise .all([ diff --git a/js/src/modals/CreateAccount/store.spec.js b/js/src/modals/CreateAccount/store.spec.js index 833cb7ef5..b02f013b6 100644 --- a/js/src/modals/CreateAccount/store.spec.js +++ b/js/src/modals/CreateAccount/store.spec.js @@ -79,8 +79,9 @@ describe('modals/CreateAccount/Store', () => { beforeEach(() => { store.setName('testing'); store.setPassword('testing'); - store.setVaultName('testing'); + store.setQrAddress('testing'); store.setRawKey('test'); + store.setVaultName('testing'); store.setWalletFile('test'); store.setWalletJson('test'); }); @@ -92,6 +93,7 @@ describe('modals/CreateAccount/Store', () => { expect(store.nameError).to.be.null; expect(store.password).to.equal(''); expect(store.passwordRepeatError).to.be.null; + expect(store.qrAddress).to.be.null; expect(store.rawKey).to.equal(''); expect(store.rawKeyError).to.be.null; 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', () => { it('sets error when empty key', () => { store.setRawKey(null); @@ -402,10 +418,34 @@ describe('modals/CreateAccount/Store', () => { }); 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', () => { let createAccountFromGethSpy; let createAccountFromWalletSpy; let createAccountFromPhraseSpy; + let createAccountFromQrSpy; let createAccountFromRawSpy; let busySpy; @@ -413,6 +453,7 @@ describe('modals/CreateAccount/Store', () => { createAccountFromGethSpy = sinon.spy(store, 'createAccountFromGeth'); createAccountFromWalletSpy = sinon.spy(store, 'createAccountFromWallet'); createAccountFromPhraseSpy = sinon.spy(store, 'createAccountFromPhrase'); + createAccountFromQrSpy = sinon.spy(store, 'createAccountFromQr'); createAccountFromRawSpy = sinon.spy(store, 'createAccountFromRaw'); busySpy = sinon.spy(store, 'setBusy'); }); @@ -422,6 +463,7 @@ describe('modals/CreateAccount/Store', () => { store.createAccountFromWallet.restore(); store.createAccountFromPhrase.restore(); store.createAccountFromRaw.restore(); + store.createAccountFromQr.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', () => { store.setCreateType('fromRaw'); @@ -516,6 +566,8 @@ describe('modals/CreateAccount/Store', () => { it('sets the account meta', () => { return store.createAccountFromGeth(-1).then(() => { expect(store._api.parity.setAccountMeta).to.have.been.calledWith(GETH_ADDRESSES[0], { + description: 'Imported from Geth keystore', + passwordHint: '', timestamp: -1 }); }); @@ -552,6 +604,7 @@ describe('modals/CreateAccount/Store', () => { it('sets the account meta', () => { return store.createAccountFromPhrase(-1).then(() => { expect(store._api.parity.setAccountMeta).to.have.been.calledWith(ADDRESS, { + description: '', passwordHint: 'some hint', 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', () => { beforeEach(() => { store.setName('some name'); @@ -603,6 +679,7 @@ describe('modals/CreateAccount/Store', () => { it('sets the account meta', () => { return store.createAccountFromRaw(-1).then(() => { expect(store._api.parity.setAccountMeta).to.have.been.calledWith(ADDRESS, { + description: '', passwordHint: 'some hint', timestamp: -1 }); @@ -639,6 +716,7 @@ describe('modals/CreateAccount/Store', () => { it('sets the account meta', () => { return store.createAccountFromWallet(-1).then(() => { expect(store._api.parity.setAccountMeta).to.have.been.calledWith(ADDRESS, { + description: '', passwordHint: 'some hint', timestamp: -1 }); diff --git a/js/src/modals/FirstRun/firstRun.js b/js/src/modals/FirstRun/firstRun.js index 7a7c95c45..1376a71b0 100644 --- a/js/src/modals/FirstRun/firstRun.js +++ b/js/src/modals/FirstRun/firstRun.js @@ -124,12 +124,12 @@ class FirstRun extends Component { return ( <NewAccount newError={ this.props.newError } - store={ this.createStore } + createStore={ this.createStore } /> ); case 3: return ( - <AccountDetails store={ this.createStore } /> + <AccountDetails createStore={ this.createStore } /> ); case 4: return ( diff --git a/js/src/redux/providers/personalActions.js b/js/src/redux/providers/personalActions.js index edafbef31..6200537c3 100644 --- a/js/src/redux/providers/personalActions.js +++ b/js/src/redux/providers/personalActions.js @@ -48,6 +48,9 @@ export function personalAccountsInfo (accountsInfo) { account.hardware = true; hardware[account.address] = account; accounts[account.address] = account; + } else if (account.meta.external) { + account.external = true; + accounts[account.address] = account; } else { contacts[account.address] = account; } diff --git a/js/src/redux/providers/signerMiddleware.js b/js/src/redux/providers/signerMiddleware.js index 3f427044e..f50057abe 100644 --- a/js/src/redux/providers/signerMiddleware.js +++ b/js/src/redux/providers/signerMiddleware.js @@ -18,6 +18,7 @@ import * as actions from './signerActions'; import { inHex } from '~/api/format/input'; import HardwareStore from '~/mobx/hardwareStore'; +import { createSignedTx } from '~/util/qrscan'; import { Signer } from '~/util/signer'; export default class SignerMiddleware { @@ -86,14 +87,25 @@ export default class SignerMiddleware { return this._hwstore.signLedger(transaction); }) .then((rawTx) => { - const handlePromise = this._createConfirmPromiseHandler(store, id); - - return handlePromise(this._api.signer.confirmRequestRaw(id, rawTx)); + return this.confirmRawTransaction(store, id, rawTx); }); } - confirmWalletTransaction (store, id, transaction, wallet, password) { + confirmRawTransaction (store, id, rawTx) { 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 signerPromise = worker && worker._worker.state === 'activated' @@ -126,7 +138,7 @@ export default class SignerMiddleware { return signer.signTransaction(txData); }) .then((rawTx) => { - return handlePromise(this._api.signer.confirmRequestRaw(id, rawTx)); + return this.confirmRawTransaction(store, id, rawTx); }) .catch((error) => { console.error(error.message); @@ -135,7 +147,7 @@ export default class SignerMiddleware { } 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 transaction = payload.sendTransaction || payload.signTransaction; @@ -144,6 +156,8 @@ export default class SignerMiddleware { if (wallet) { return this.confirmWalletTransaction(store, id, transaction, wallet, password); + } else if (txSigned) { + return this.confirmSignedTransaction(store, id, txSigned); } else if (hardwareAccount) { switch (hardwareAccount.via) { case 'ledger': diff --git a/js/src/ui/GasPriceEditor/store.js b/js/src/ui/GasPriceEditor/store.js index 573654c51..a7a3b914b 100644 --- a/js/src/ui/GasPriceEditor/store.js +++ b/js/src/ui/GasPriceEditor/store.js @@ -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) => { transaction(() => { this.conditionBlockError = null; @@ -224,11 +236,8 @@ export default class GasPriceEditor { switch (this.conditionType) { case CONDITIONS.BLOCK: - result.condition = { block: new BigNumber(this.condition.block || 0) }; - break; - case CONDITIONS.TIME: - result.condition = { time: this.condition.time }; + result.condition = this.conditionValue; break; case CONDITIONS.NONE: diff --git a/js/src/ui/Icons/index.js b/js/src/ui/Icons/index.js index 04f24918c..e637a239a 100644 --- a/js/src/ui/Icons/index.js +++ b/js/src/ui/Icons/index.js @@ -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 NextIcon from 'material-ui/svg-icons/navigation/arrow-forward'; 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 PrevIcon from 'material-ui/svg-icons/navigation/arrow-back'; export PrintIcon from 'material-ui/svg-icons/action/print'; diff --git a/js/src/ui/QrCode/qrCode.css b/js/src/ui/QrCode/qrCode.css new file mode 100644 index 000000000..b6b524b44 --- /dev/null +++ b/js/src/ui/QrCode/qrCode.css @@ -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; + } +} diff --git a/js/src/ui/QrCode/qrCode.js b/js/src/ui/QrCode/qrCode.js index 26033f8f3..fddcf089e 100644 --- a/js/src/ui/QrCode/qrCode.js +++ b/js/src/ui/QrCode/qrCode.js @@ -14,14 +14,17 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see <http://www.gnu.org/licenses/>. -// https://github.com/cmanzana/qrcode-npm packaging the standard -// https://github.com/kazuhikoarase/qrcode-generator -import { qrcode } from 'qrcode-npm'; +import qrcode from 'qrcode-generator/js/qrcode'; import React, { Component, PropTypes } from 'react'; +import { calculateType } from './qrSize'; + +import styles from './qrCode.css'; + const QROPTS = { - CODE_TYPE: 4, - ERROR_LEVEL: 'M' + ERROR_LEVEL: 'M', + MAX_SIZE: 40, + MIN_SIZE: 5 }; export default class QrCode extends Component { @@ -61,7 +64,7 @@ export default class QrCode extends Component { return ( <div - className={ className } + className={ [styles.qr, className].join(' ') } dangerouslySetInnerHTML={ { __html: image } } @@ -71,9 +74,10 @@ export default class QrCode extends Component { generateCode (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(); this.setState({ diff --git a/js/src/ui/QrCode/qrSize.js b/js/src/ui/QrCode/qrSize.js new file mode 100644 index 000000000..2671bb532 --- /dev/null +++ b/js/src/ui/QrCode/qrSize.js @@ -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; +} diff --git a/js/src/ui/QrScan/index.js b/js/src/ui/QrScan/index.js new file mode 100644 index 000000000..55c59d7c1 --- /dev/null +++ b/js/src/ui/QrScan/index.js @@ -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'; diff --git a/js/src/ui/QrScan/qrScan.css b/js/src/ui/QrScan/qrScan.css new file mode 100644 index 000000000..67a7dee66 --- /dev/null +++ b/js/src/ui/QrScan/qrScan.css @@ -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; + } +} diff --git a/js/src/ui/QrScan/qrScan.js b/js/src/ui/QrScan/qrScan.js new file mode 100644 index 000000000..d75e7d0e9 --- /dev/null +++ b/js/src/ui/QrScan/qrScan.js @@ -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); + } +}; diff --git a/js/src/ui/QrScan/qrScan.spec.js b/js/src/ui/QrScan/qrScan.spec.js new file mode 100644 index 000000000..9e4c96f74 --- /dev/null +++ b/js/src/ui/QrScan/qrScan.spec.js @@ -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); + }); + }); +}); diff --git a/js/src/ui/index.js b/js/src/ui/index.js index 349cc7fe4..1cb0e468b 100644 --- a/js/src/ui/index.js +++ b/js/src/ui/index.js @@ -46,6 +46,7 @@ export Page from './Page'; export ParityBackground from './ParityBackground'; export Portal from './Portal'; export QrCode from './QrCode'; +export QrScan from './QrScan'; export ScrollableText from './ScrollableText'; export SectionList from './SectionList'; export SelectionList from './SelectionList'; diff --git a/js/src/util/qrscan.js b/js/src/util/qrscan.js new file mode 100644 index 000000000..eabc95409 --- /dev/null +++ b/js/src/util/qrscan.js @@ -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; + }); +} diff --git a/js/src/util/signer.js b/js/src/util/signer.js index 07a64b527..1194ff251 100644 --- a/js/src/util/signer.js +++ b/js/src/util/signer.js @@ -94,10 +94,15 @@ export class Signer { this.seed = seed; } + signTransactionObject (tx) { + tx.sign(this.seed); + + return tx; + } + signTransaction (transaction) { const tx = new Transaction(transaction); - tx.sign(this.seed); - return inHex(tx.serialize().toString('hex')); + return inHex(this.signTransactionObject(tx).serialize().toString('hex')); } } diff --git a/js/src/views/Account/account.js b/js/src/views/Account/account.js index c2b600be8..98f02afe3 100644 --- a/js/src/views/Account/account.js +++ b/js/src/views/Account/account.js @@ -220,7 +220,7 @@ class Account extends Component { } onClick={ this.store.toggleEditDialog } />, - !account.hardware && ( + !(account.external || account.hardware) && ( <Button icon={ <LockedIcon /> } key='passwordManager' @@ -237,10 +237,19 @@ class Account extends Component { icon={ <DeleteIcon /> } key='delete' label={ - <FormattedMessage - id='account.button.delete' - defaultMessage='delete' - /> + account.external || account.hardware + ? ( + <FormattedMessage + id='account.button.forget' + defaultMessage='forget' + /> + ) + : ( + <FormattedMessage + id='account.button.delete' + defaultMessage='delete' + /> + ) } 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 ( <DeleteAccount account={ account } diff --git a/js/src/views/Accounts/accounts.js b/js/src/views/Accounts/accounts.js index 107fcc248..668db1e47 100644 --- a/js/src/views/Accounts/accounts.js +++ b/js/src/views/Accounts/accounts.js @@ -110,7 +110,7 @@ class Accounts extends Component { } /> - { this.renderHwWallets() } + { this.renderExternalAccounts() } { this.renderWallets() } { this.renderAccounts() } </Page> @@ -182,13 +182,14 @@ class Accounts extends Component { ); } - renderHwWallets () { + renderExternalAccounts () { const { accounts, balances } = this.props; const { wallets } = this.hwstore; 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; } @@ -208,7 +209,7 @@ class Accounts extends Component { return ( <List search={ searchValues } - accounts={ hardware } + accounts={ all } balances={ balances } disabled={ disabled } order={ sortOrder } diff --git a/js/src/views/ParityBar/accountStore.js b/js/src/views/ParityBar/accountStore.js index 2b0fe17a0..3d4dbae9c 100644 --- a/js/src/views/ParityBar/accountStore.js +++ b/js/src/views/ParityBar/accountStore.js @@ -80,10 +80,11 @@ export default class AccountStore { .keys(allAccounts) .filter((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); - return isAccount && isWhitelisted; + return (isAccount || isExternal) && isWhitelisted; }) .map((address) => { return { diff --git a/js/src/views/Signer/components/TransactionPending/transactionPending.js b/js/src/views/Signer/components/TransactionPending/transactionPending.js index 90ea75b22..666ff16a1 100644 --- a/js/src/views/Signer/components/TransactionPending/transactionPending.js +++ b/js/src/views/Signer/components/TransactionPending/transactionPending.js @@ -127,9 +127,12 @@ class TransactionPending extends Component { address={ from } disabled={ disabled } focus={ focus } + gasStore={ this.gasStore } isSending={ isSending } + netVersion={ netVersion } onConfirm={ this.onConfirm } onReject={ this.onReject } + transaction={ transaction } /> </div> ); @@ -157,7 +160,7 @@ class TransactionPending extends Component { onConfirm = (data) => { const { id, transaction } = this.props; - const { password, wallet } = data; + const { password, txSigned, wallet } = data; const { condition, gas, gasPrice } = this.gasStore.overrideTransaction(transaction); const options = { @@ -165,6 +168,7 @@ class TransactionPending extends Component { gasPrice, id, password, + txSigned, wallet }; diff --git a/js/src/views/Signer/components/TransactionPendingForm/TransactionPendingFormConfirm/transactionPendingFormConfirm.css b/js/src/views/Signer/components/TransactionPendingForm/TransactionPendingFormConfirm/transactionPendingFormConfirm.css index 77133fd6f..f915d1274 100644 --- a/js/src/views/Signer/components/TransactionPendingForm/TransactionPendingFormConfirm/transactionPendingFormConfirm.css +++ b/js/src/views/Signer/components/TransactionPendingForm/TransactionPendingFormConfirm/transactionPendingFormConfirm.css @@ -44,3 +44,8 @@ .fileInput input { top: 22px; } + +.qr { + margin-bottom: 0.5em; + text-align: center; +} diff --git a/js/src/views/Signer/components/TransactionPendingForm/TransactionPendingFormConfirm/transactionPendingFormConfirm.js b/js/src/views/Signer/components/TransactionPendingForm/TransactionPendingFormConfirm/transactionPendingFormConfirm.js index 8a20b3332..0152bb71d 100644 --- a/js/src/views/Signer/components/TransactionPendingForm/TransactionPendingFormConfirm/transactionPendingFormConfirm.js +++ b/js/src/views/Signer/components/TransactionPendingForm/TransactionPendingFormConfirm/transactionPendingFormConfirm.js @@ -21,18 +21,30 @@ import ReactDOM from 'react-dom'; import { FormattedMessage } from 'react-intl'; 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'; +const QR_VISIBLE = 1; +const QR_SCAN = 2; +const QR_COMPLETED = 3; + export default class TransactionPendingFormConfirm extends Component { + static contextTypes = { + api: PropTypes.object.isRequired + }; + static propTypes = { account: PropTypes.object, address: PropTypes.string.isRequired, disabled: PropTypes.bool, + focus: PropTypes.bool, + gasStore: PropTypes.object.isRequired, + netVersion: PropTypes.string.isRequired, isSending: PropTypes.bool.isRequired, onConfirm: PropTypes.func.isRequired, - focus: PropTypes.bool + transaction: PropTypes.object.isRequired }; static defaultProps = { @@ -44,6 +56,8 @@ export default class TransactionPendingFormConfirm extends Component { state = { password: '', + qrState: QR_VISIBLE, + qr: {}, wallet: null, walletError: null } @@ -52,6 +66,15 @@ export default class TransactionPendingFormConfirm extends Component { this.focus(); } + componentWillMount () { + this.readNonce(); + this.subscribeNonce(); + } + + componentWillUnmount () { + this.unsubscribeNonce(); + } + componentWillReceiveProps (nextProps) { if (!this.props.focus && nextProps.focus) { this.focus(nextProps); @@ -93,64 +116,96 @@ export default class TransactionPendingFormConfirm extends Component { 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 () { const { account, address, disabled, isSending } = this.props; 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 ( <div className={ styles.confirmForm }> <Form> { this.renderKeyInput() } + { this.renderQrCode() } + { this.renderQrScanner() } { this.renderPassword() } { this.renderHint() } - <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={ - isSending - ? ( - <FormattedMessage - id='signer.txPendingConfirm.buttons.confirmBusy' - defaultMessage='Confirming...' - /> - ) - : ( - <FormattedMessage - id='signer.txPendingConfirm.buttons.confirmRequest' - defaultMessage='Confirm Request' - /> - ) - } - onTouchTap={ this.onConfirm } - primary - /> - </div> + { confirmButton } { this.renderTooltip() } </Form> </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 () { const { account } = this.props; const { password } = this.state; - if (account.hardware) { + if (account.hardware || account.external) { return null; } @@ -199,6 +254,34 @@ export default class TransactionPendingFormConfirm extends Component { renderHint () { 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 (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 () { const { account } = this.props; const { walletError } = this.state; - if (account.uuid || account.wallet || account.hardware) { + if (account.uuid || account.wallet || account.hardware || account.external) { return null; } @@ -274,8 +391,8 @@ export default class TransactionPendingFormConfirm extends Component { renderTooltip () { const { account } = this.props; - if (this.state.password.length || account.hardware) { - return; + if (this.state.password.length || account.hardware || account.external) { + return null; } 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) => { // Check that file have been selected if (event.target.files.length === 0) { @@ -338,7 +474,12 @@ export default class TransactionPendingFormConfirm extends Component { } 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({ 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) => { const codeName = keycode(event); @@ -355,4 +505,43 @@ export default class TransactionPendingFormConfirm extends Component { 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(); + } + }); + } } diff --git a/js/src/views/Signer/components/TransactionPendingForm/transactionPendingForm.js b/js/src/views/Signer/components/TransactionPendingForm/transactionPendingForm.js index 73e3bb7ef..af360897a 100644 --- a/js/src/views/Signer/components/TransactionPendingForm/transactionPendingForm.js +++ b/js/src/views/Signer/components/TransactionPendingForm/transactionPendingForm.js @@ -27,12 +27,15 @@ export default class TransactionPendingForm extends Component { static propTypes = { account: PropTypes.object, address: PropTypes.string.isRequired, + className: PropTypes.string, disabled: PropTypes.bool, + focus: PropTypes.bool, + gasStore: PropTypes.object.isRequired, + netVersion: PropTypes.string.isRequired, isSending: PropTypes.bool.isRequired, onConfirm: PropTypes.func.isRequired, onReject: PropTypes.func.isRequired, - className: PropTypes.string, - focus: PropTypes.bool + transaction: PropTypes.object.isRequired }; static defaultProps = { @@ -56,7 +59,7 @@ export default class TransactionPendingForm extends Component { } 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) { return ( @@ -70,8 +73,11 @@ export default class TransactionPendingForm extends Component { account={ account } disabled={ disabled } focus={ focus } + gasStore={ gasStore } + netVersion={ netVersion } isSending={ isSending } onConfirm={ onConfirm } + transaction={ transaction } /> ); } diff --git a/js/test/mocha.config.js b/js/test/mocha.config.js index 92fa79bbd..771e14040 100644 --- a/js/test/mocha.config.js +++ b/js/test/mocha.config.js @@ -44,6 +44,7 @@ global.document = jsdom.jsdom('<!doctype html><html><body></body></html>'); global.window = document.defaultView; global.navigator = global.window.navigator; global.location = global.window.location; +global.Blob = () => {}; // attach mocked localStorage onto the window as exposed by jsdom global.window.localStorage = global.localStorage;