diff --git a/features/accountManagement.feature b/features/accountManagement.feature new file mode 100644 index 000000000..fd7967e36 --- /dev/null +++ b/features/accountManagement.feature @@ -0,0 +1,47 @@ +Feature: Account management + Scenario: should allow to save account locally, after page reload it should require passphrase to do the first transaction, and remember the passphrase for next transactions + Given I'm logged in as "genesis" + When I click "save account" in main menu + And I click "save account button" + And I wait 1 seconds + And I should see text "Account saved" in "toast" element + And I Refresh the page + And I wait 2 seconds + Then I should be logged in + And I click "send button" + And I should see empty "passphrase" field + And I fill in "1" to "amount" field + And I fill in "537318935439898807L" to "recipient" field + And I fill in passphrase of "genesis" to "passphrase" field + And I click "submit button" + And I click "ok button" + And I wait 1 seconds + And I click "send button" + And I fill in "2" to "amount" field + And I fill in "537318935439898807L" to "recipient" field + And I click "submit button" + And I should see alert dialog with title "Success" and text "Your transaction of 2 LSK to 537318935439898807L was accepted and will be processed in a few seconds." + + Scenario: should allow to forget locally saved account + Given I'm logged in as "any account" + When I click "save account" in main menu + And I click "save account button" + And I Refresh the page + And I wait 2 seconds + And I click "forget account" in main menu + And I wait 1 seconds + Then I should see text "Account was successfully forgotten." in "toast" element + And I Refresh the page + And I should be on login page + + Scenario: should allow to exit save account dialog with "cancel button" + Given I'm logged in as "any account" + When I click "save account" in main menu + And I click "cancel button" + Then I should see no "modal dialog" + + Scenario: should allow to exit save account dialog with "x button" + Given I'm logged in as "any account" + When I click "save account" in main menu + And I click "x button" + Then I should see no "modal dialog" diff --git a/features/step_definitions/generic.step.js b/features/step_definitions/generic.step.js index c8d3d8a6f..ce6dc887f 100644 --- a/features/step_definitions/generic.step.js +++ b/features/step_definitions/generic.step.js @@ -32,6 +32,13 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { waitForElemAndSendKeys(`${selectorClass} input, ${selectorClass} textarea`, secondPassphrase, callback); }); + When('I fill in passphrase of "{accountName}" to "{fieldName}" field', (accountName, fieldName, callback) => { + const selectorClass = `.${fieldName.replace(/ /g, '-')}`; + const passphrase = accounts[accountName].passphrase; + browser.sleep(500); + waitForElemAndSendKeys(`${selectorClass} input, ${selectorClass} textarea`, passphrase, callback); + }); + When('I wait {seconds} seconds', (seconds, callback) => { browser.sleep(seconds * 1000).then(callback); }); @@ -43,6 +50,12 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { .and.notify(callback); }); + Then('I should see empty "{fieldName}" field', (fieldName, callback) => { + const elem = element(by.css(`.${fieldName.replace(/ /g, '-')} input, .${fieldName.replace(/ /g, '-')} textarea`)); + expect(elem.getAttribute('value')).to.eventually.equal('') + .and.notify(callback); + }); + When('I click "{elementName}"', (elementName, callback) => { const selector = `.${elementName.replace(/\s+/g, '-')}`; waitForElemAndClickIt(selector, callback); @@ -110,6 +123,7 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { browser.ignoreSynchronization = true; browser.driver.manage().window().setSize(1000, 1000); browser.get('http://localhost:8080/'); + localStorage.clear(); localStorage.setItem('address', 'http://localhost:4000'); localStorage.setItem('network', 2); browser.get('http://localhost:8080/'); @@ -151,5 +165,9 @@ defineSupportCode(({ Given, When, Then, setDefaultTimeout }) => { }); }); }); + + When('I Refresh the page', (callback) => { + browser.refresh().then(callback); + }); }); diff --git a/src/actions/account.js b/src/actions/account.js index ef800538e..566d67ae9 100644 --- a/src/actions/account.js +++ b/src/actions/account.js @@ -40,6 +40,11 @@ export const accountLoggedIn = data => ({ data, }); +export const passphraseUsed = data => ({ + type: actionTypes.passphraseUsed, + data, +}); + /** * */ @@ -59,14 +64,16 @@ export const secondPassphraseRegistered = ({ activePeer, secondPassphrase, accou const text = (error && error.message) ? error.message : 'An error occurred while registering your second passphrase. Please try again.'; dispatch(errorAlertDialogDisplayed({ text })); }); + dispatch(passphraseUsed(account.passphrase)); }; /** * */ -export const delegateRegistered = ({ activePeer, account, username, secondPassphrase }) => +export const delegateRegistered = ({ + activePeer, account, passphrase, username, secondPassphrase }) => (dispatch) => { - registerDelegate(activePeer, username, account.passphrase, secondPassphrase) + registerDelegate(activePeer, username, passphrase, secondPassphrase) .then((data) => { // dispatch to add to pending transaction dispatch(transactionAdded({ @@ -84,6 +91,7 @@ export const delegateRegistered = ({ activePeer, account, username, secondPassph const actionObj = errorAlertDialogDisplayed({ text }); dispatch(actionObj); }); + dispatch(passphraseUsed(passphrase)); }; /** @@ -107,4 +115,5 @@ export const sent = ({ activePeer, account, recipientId, amount, passphrase, sec const text = error && error.message ? `${error.message}.` : 'An error occurred while creating the transaction.'; dispatch(errorAlertDialogDisplayed({ text })); }); + dispatch(passphraseUsed(passphrase)); }; diff --git a/src/actions/peers.js b/src/actions/peers.js index 72aff148d..74752fe3d 100644 --- a/src/actions/peers.js +++ b/src/actions/peers.js @@ -34,6 +34,7 @@ export const activePeerSet = (data) => { return { data: Object.assign({ passphrase: data.passphrase, + publicKey: data.publicKey, activePeer: Lisk.api(config), }), type: actionTypes.activePeerSet, diff --git a/src/actions/voting.js b/src/actions/voting.js index bcb4e9053..e99b11101 100644 --- a/src/actions/voting.js +++ b/src/actions/voting.js @@ -1,8 +1,9 @@ -import actionTypes from '../constants/actions'; -import { vote } from '../utils/api/delegate'; -import { transactionAdded } from './transactions'; import { errorAlertDialogDisplayed } from './dialog'; +import { passphraseUsed } from './account'; +import { transactionAdded } from './transactions'; +import { vote } from '../utils/api/delegate'; import Fees from '../constants/fees'; +import actionTypes from '../constants/actions'; /** * Add pending variable to the list of voted delegates and list of unvoted delegates @@ -21,12 +22,13 @@ export const clearVoteLists = () => ({ /** * */ -export const votePlaced = ({ activePeer, account, votedList, unvotedList, secondSecret }) => +export const votePlaced = ({ + activePeer, passphrase, account, votedList, unvotedList, secondSecret }) => (dispatch) => { // Make the Api call vote( activePeer, - account.passphrase, + passphrase, account.publicKey, votedList, unvotedList, @@ -45,11 +47,11 @@ export const votePlaced = ({ activePeer, account, votedList, unvotedList, second fee: Fees.vote, type: 3, })); - }) - .catch((error) => { + }).catch((error) => { const text = error && error.message ? `${error.message}.` : 'An error occurred while placing your vote.'; dispatch(errorAlertDialogDisplayed({ text })); }); + dispatch(passphraseUsed(account.passphrase)); }; /** diff --git a/src/components/account/account.css b/src/components/account/account.css index 3d696b4eb..744613b6e 100644 --- a/src/components/account/account.css +++ b/src/components/account/account.css @@ -71,6 +71,7 @@ position: absolute; top: 5px; right: 5px; + z-index: 1; } } diff --git a/src/components/account/address.js b/src/components/account/address.js index ea8dd8ab0..2e584e4ae 100644 --- a/src/components/account/address.js +++ b/src/components/account/address.js @@ -1,7 +1,18 @@ import React from 'react'; import grid from 'flexboxgrid/dist/flexboxgrid.css'; + +import { TooltipWrapper } from '../timestamp'; import styles from './account.css'; +const getStatusTooltip = (props) => { + if (props.secondSignature) { + return 'This account is protected by a second passphrase'; + } else if (props.passphrase) { + return 'Passphrase of the acount is saved till the end of the session.'; + } + return 'Passphrase of the acount will be required to perform any transaction.'; +}; + const Address = (props) => { const title = props.isDelegate ? 'Delegate' : 'Address'; const content = props.isDelegate ? @@ -26,6 +37,11 @@ const Address = (props) => {
{content} + + + {props.passphrase && !props.secondSignature ? 'lock_open' : 'lock'} + +
diff --git a/src/components/authInputs/authInputs.js b/src/components/authInputs/authInputs.js new file mode 100644 index 000000000..f48d24393 --- /dev/null +++ b/src/components/authInputs/authInputs.js @@ -0,0 +1,46 @@ +import React from 'react'; +import PassphraseInput from '../passphraseInput'; +import { extractPublicKey } from '../../utils/api/account'; + +class AuthInputs extends React.Component { + componentDidMount() { + if (this.props.account.secondSignature) { + this.props.onChange('secondPassphrase', ''); + } + } + + onChange(name, value, error) { + if (!error) { + const publicKeyMap = { + passphrase: 'publicKey', + secondPassphrase: 'secondPublicKey', + }; + const expectedPublicKey = this.props.account[publicKeyMap[name]]; + + if (expectedPublicKey && expectedPublicKey !== extractPublicKey(value)) { + error = 'Entered passphrase does not belong to the active account'; + } + } + this.props.onChange(name, value, error); + } + + render() { + return + {(!this.props.account.passphrase && + )} + {(this.props.account.secondSignature && + )} + ; + } +} + +export default AuthInputs; + diff --git a/src/components/authInputs/authInputs.test.js b/src/components/authInputs/authInputs.test.js new file mode 100644 index 000000000..612b3688e --- /dev/null +++ b/src/components/authInputs/authInputs.test.js @@ -0,0 +1,73 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import sinon from 'sinon'; +import AuthInputs from './authInputs'; + + +describe('AuthInputs', () => { + let wrapper; + let props; + const passphrase = 'recipe bomb asset salon coil symbol tiger engine assist pact pumpkin visit'; + + beforeEach(() => { + props = { + onChange: sinon.spy(), + secondPassphrase: { }, + account: { + passphrase, + }, + passphrase: { + value: passphrase, + }, + }; + }); + + it('should render Input if props.account.secondSignature', () => { + props.account.secondSignature = true; + wrapper = mount(); + expect(wrapper.find('Input')).to.have.lengthOf(1); + }); + + it('should render null if !props.account.secondSignature', () => { + props.account.secondSignature = false; + wrapper = mount(); + expect(wrapper.html()).to.equal(''); + }); + + it('should render null if !props.account.secondSignature', () => { + props.account.secondSignature = false; + wrapper = mount(); + expect(wrapper.html()).to.equal(''); + }); + + it('should call props.onChange when input value changes', () => { + props.account.secondSignature = true; + wrapper = mount(); + wrapper.find('.second-passphrase input').simulate('change', { target: { value: passphrase } }); + expect(props.onChange).to.have.been.calledWith('secondPassphrase', passphrase); + }); + + it('should call props.onChange with an error if entered secondPassphrase does not belong to secondPublicKey', () => { + const error = 'Entered passphrase does not belong to the active account'; + props.account.secondSignature = true; + props.account.secondPublicKey = 'fab9d261ea050b9e326d7e11587eccc343a20e64e29d8781b50fd06683cacc88'; + wrapper = mount(); + wrapper.find('.second-passphrase input').simulate('change', { target: { value: passphrase } }); + expect(props.onChange).to.have.been.calledWith('secondPassphrase', passphrase, error); + }); + + it('should call props.onChange(\'secondPassphrase\', \'Required\') when input value changes to \'\'', () => { + props.account.secondSignature = true; + wrapper = mount(); + wrapper.find('.second-passphrase input').simulate('change', { target: { value: '' } }); + expect(props.onChange).to.have.been.calledWith('secondPassphrase', '', 'Required'); + }); + + it('should call props.onChange(\'secondPassphrase\', \'Invalid passphrase\') when input value changes to \'test\'', () => { + props.account.secondSignature = true; + wrapper = mount(); + wrapper.find('.second-passphrase input').simulate('change', { target: { value: 'test' } }); + expect(props.onChange).to.have.been.calledWith('secondPassphrase', 'test', 'Passphrase should have 12 words, entered passphrase has 1'); + }); +}); diff --git a/src/components/authInputs/index.js b/src/components/authInputs/index.js new file mode 100644 index 000000000..c122d318f --- /dev/null +++ b/src/components/authInputs/index.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import AuthInputs from './authInputs'; + +const mapStateToProps = state => ({ + account: state.account, +}); + +export default connect(mapStateToProps)(AuthInputs); + diff --git a/src/components/authInputs/index.test.js b/src/components/authInputs/index.test.js new file mode 100644 index 000000000..772185bfd --- /dev/null +++ b/src/components/authInputs/index.test.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import AuthInputsHOC from './index'; + +describe('AuthInputsHOC', () => { + let wrapper; + const passphrase = 'recipe bomb asset salon coil symbol tiger engine assist pact pumpkin visit'; + const props = { + onChange: () => {}, + secondPassphrase: {}, + }; + const account = { + secondSignature: 1, + passphrase, + }; + + it('should render AuthInputs with props.account equal to state.account ', () => { + const store = configureMockStore([])({ account }); + wrapper = mount( + + ); + expect(wrapper.find('AuthInputs').props().account).to.deep.equal(account); + }); +}); diff --git a/src/components/header/header.js b/src/components/header/header.js index bac432ad0..8f66b5a69 100644 --- a/src/components/header/header.js +++ b/src/components/header/header.js @@ -1,6 +1,6 @@ import React from 'react'; import { Button } from 'react-toolbox/lib/button'; -import { IconMenu, MenuItem } from 'react-toolbox/lib/menu'; +import { IconMenu, MenuItem, MenuDivider } from 'react-toolbox/lib/menu'; import grid from 'flexboxgrid/dist/flexboxgrid.css'; import logo from '../../assets/images/LISK-nano.png'; import styles from './header.css'; @@ -11,6 +11,7 @@ import Send from '../send'; import PrivateWrapper from '../privateWrapper'; import SecondPassphraseMenu from '../secondPassphrase'; import offlineStyle from '../offlineWrapper/offlineWrapper.css'; +import SaveAccountButton from '../saveAccountButton'; const Header = props => (
@@ -53,6 +54,8 @@ const Header = props => ( childComponent: VerifyMessage, })} /> + +