diff --git a/i18n/locales/en/common.json b/i18n/locales/en/common.json index b0ffbd0f76..3b85c1e37f 100644 --- a/i18n/locales/en/common.json +++ b/i18n/locales/en/common.json @@ -100,6 +100,7 @@ "Lisk Hub": "Lisk Hub", "Lisk ID": "Lisk ID", "Lisk Website": "Lisk Website", + "Lock ID": "Lock ID", "Lock ID’s automatically after 10 minutes.": "Lock ID’s automatically after 10 minutes.", "Log in": "Log in", "Losing access to this passphrase will mean no funds can be sent from this account.": "Losing access to this passphrase will mean no funds can be sent from this account.", @@ -202,7 +203,6 @@ "Unable to connect to the node": "Unable to connect to the node", "Undo": "Undo", "Unlock account": "Unlock account", - "Unlocked": "Unlocked", "Update download finished": "Update download finished", "Update now": "Update now", "Updates downloaded, application has to be restarted to apply the updates.": "Updates downloaded, application has to be restarted to apply the updates.", @@ -231,6 +231,7 @@ "You only need to do this once for each Lisk ID.": "You only need to do this once for each Lisk ID.", "You will send a small amount of {{fee}} LSK to yourself and therefore initialize your ID.": "You will send a small amount of {{fee}} LSK to yourself and therefore initialize your ID.", "You've received {{value}} LSK.": "You've received {{value}} LSK.", + "Your ID is now secured!": "Your ID is now secured!", "Your Lisk IDs": "Your Lisk IDs", "You’re votes are being processed and will be confirmed. It may take up to 10 minutes to be secured in the blockchain.": "You’re votes are being processed and will be confirmed. It may take up to 10 minutes to be secured in the blockchain.", "Zero not allowed": "Zero not allowed", diff --git a/src/actions/account.js b/src/actions/account.js index 383d45a754..205ea8a0a3 100644 --- a/src/actions/account.js +++ b/src/actions/account.js @@ -11,9 +11,11 @@ import transactionTypes from '../constants/transactionTypes'; /** * Trigger this action to remove passphrase from account object * + * @param {Object} data - account data * @returns {Object} - Action object */ -export const removePassphrase = () => ({ +export const removePassphrase = data => ({ + data, type: actionTypes.removePassphrase, }); diff --git a/src/actions/account.test.js b/src/actions/account.test.js index 1805b23415..386edad714 100644 --- a/src/actions/account.test.js +++ b/src/actions/account.test.js @@ -2,7 +2,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import actionTypes from '../constants/actions'; import { accountUpdated, accountLoggedOut, - secondPassphraseRegistered, delegateRegistered, sent } from './account'; + secondPassphraseRegistered, delegateRegistered, sent, removePassphrase } from './account'; import { transactionAdded, transactionFailed } from './transactions'; import { errorAlertDialogDisplayed } from './dialog'; import * as accountApi from '../utils/api/account'; @@ -10,6 +10,8 @@ import * as delegateApi from '../utils/api/delegate'; import Fees from '../constants/fees'; import { toRawLsk } from '../utils/lsk'; import transactionTypes from '../constants/transactionTypes'; +import networks from '../constants/networks'; +import accounts from '../../test/constants/accounts'; describe('actions: account', () => { describe('accountUpdated', () => { @@ -215,4 +217,21 @@ describe('actions: account', () => { expect(dispatch).to.have.been.calledWith(expectedAction); }); }); + + describe('removePassphrase', () => { + it('should create an action to remove passphrase', () => { + const data = { + publicKey: accounts.genesis.publicKey, + network: networks.testnet, + address: accounts.genesis.address, + }; + + const expectedAction = { + data, + type: actionTypes.removePassphrase, + }; + + expect(removePassphrase(data)).to.be.deep.equal(expectedAction); + }); + }); }); diff --git a/src/actions/savedAccounts.js b/src/actions/savedAccounts.js index 2bdf54dfa6..f9f4f93202 100644 --- a/src/actions/savedAccounts.js +++ b/src/actions/savedAccounts.js @@ -50,3 +50,13 @@ export const accountsRetrieved = () => ({ export const activeAccountSaved = () => ({ type: actionTypes.activeAccountSaved, }); + +/** + * An action to dispatch removeSavedAccountPassphrase + * @param {Object} data - account data + * @returns {Object} - Action object + */ +export const removeSavedAccountPassphrase = data => ({ + data, + type: actionTypes.removeSavedAccountPassphrase, +}); diff --git a/src/actions/savedAccounts.test.js b/src/actions/savedAccounts.test.js index 9ee84bc481..2ec27a287d 100644 --- a/src/actions/savedAccounts.test.js +++ b/src/actions/savedAccounts.test.js @@ -8,6 +8,7 @@ import { accountRemoved, accountsRetrieved, activeAccountSaved, + removeSavedAccountPassphrase, } from './savedAccounts'; @@ -76,4 +77,15 @@ describe('actions: savedAccount', () => { expect(activeAccountSaved()).to.be.deep.equal(expectedAction); }); }); + + describe('removeSavedAccountPassphrase', () => { + it('should create an action to remove passphrase', () => { + const expectedAction = { + data, + type: actionTypes.removeSavedAccountPassphrase, + }; + + expect(removeSavedAccountPassphrase(data)).to.be.deep.equal(expectedAction); + }); + }); }); diff --git a/src/components/header/header.js b/src/components/header/header.js index 1364c727ec..b6855164df 100644 --- a/src/components/header/header.js +++ b/src/components/header/header.js @@ -49,16 +49,20 @@ class Header extends React.Component { {this.props.autoLog ?
- {((!this.props.account.expireTime || this.props.account.expireTime === 0)) ? - {this.props.t('Account locked!')} : + {((this.props.account.expireTime && + this.props.account.expireTime !== 0) && + this.props.account.passphrase) ?
{this.props.t('Address timeout in')} this.props.removePassphrase()} - /> -
} + onComplete={() => { + this.props.removeSavedAccountPassphrase(); + } + } + /> +
:
} :
{this.props.account.passphrase ? '' : diff --git a/src/components/header/index.js b/src/components/header/index.js index 2e35866ac7..88220b3db7 100644 --- a/src/components/header/index.js +++ b/src/components/header/index.js @@ -3,6 +3,7 @@ import { withRouter } from 'react-router'; import { translate } from 'react-i18next'; import { dialogDisplayed } from '../../actions/dialog'; import { accountLoggedOut, removePassphrase } from '../../actions/account'; +import { removeSavedAccountPassphrase } from '../../actions/savedAccounts'; import Header from './header'; const mapStateToProps = state => ({ @@ -15,7 +16,8 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ setActiveDialog: data => dispatch(dialogDisplayed(data)), logOut: () => dispatch(accountLoggedOut()), - removePassphrase: () => dispatch(removePassphrase()), + removePassphrase: data => dispatch(removePassphrase(data)), + removeSavedAccountPassphrase: () => dispatch(removeSavedAccountPassphrase()), }); export default withRouter(connect( mapStateToProps, diff --git a/src/components/header/index.test.js b/src/components/header/index.test.js index bf545ac1c3..3fde7d036f 100644 --- a/src/components/header/index.test.js +++ b/src/components/header/index.test.js @@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next'; import i18n from '../../i18n'; // initialized i18next instance import * as accountActions from '../../actions/account'; import * as dialogActions from '../../actions/dialog'; +import * as savedAccountsActions from '../../actions/savedAccounts'; import Header from './header'; import HeaderHOC from './index'; @@ -52,4 +53,18 @@ describe('HeaderHOC', () => { expect(actionsSpy).to.be.calledWith(); actionsSpy.restore(); }); + + it('should dispatch removePassphrase action', () => { + const actionsSpy = sinon.spy(accountActions, 'removePassphrase'); + wrapper.find(Header).props().removePassphrase({}); + expect(actionsSpy).to.be.calledWith({}); + actionsSpy.restore(); + }); + + it('should dispatch removeSavedAccountPassphrase action', () => { + const actionsSpy = sinon.spy(savedAccountsActions, 'removeSavedAccountPassphrase'); + wrapper.find(Header).props().removeSavedAccountPassphrase(); + expect(actionsSpy).to.be.calledWith(); + actionsSpy.restore(); + }); }); diff --git a/src/components/savedAccounts/index.js b/src/components/savedAccounts/index.js index 85f72caa1d..428336722c 100644 --- a/src/components/savedAccounts/index.js +++ b/src/components/savedAccounts/index.js @@ -1,7 +1,8 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router'; import { translate } from 'react-i18next'; -import { accountRemoved, accountSwitched } from '../../actions/savedAccounts'; +import { accountRemoved, accountSwitched, removeSavedAccountPassphrase } from '../../actions/savedAccounts'; +import { removePassphrase } from '../../actions/account'; import SavedAccounts from './savedAccounts'; const mapStateToProps = state => ({ @@ -12,6 +13,8 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ accountRemoved: data => dispatch(accountRemoved(data)), accountSwitched: data => dispatch(accountSwitched(data)), + removePassphrase: data => dispatch(removePassphrase(data)), + removeSavedAccountPassphrase: data => dispatch(removeSavedAccountPassphrase(data)), }); export default connect( diff --git a/src/components/savedAccounts/savedAccounts.css b/src/components/savedAccounts/savedAccounts.css index 57d1dab1e5..e45e348088 100644 --- a/src/components/savedAccounts/savedAccounts.css +++ b/src/components/savedAccounts/savedAccounts.css @@ -182,7 +182,8 @@ } .network, -.unlocked { +.unlocked, +.unlockedSecured { font-size: var(--font-size-h6); position: absolute; top: 0; @@ -197,6 +198,24 @@ .unlocked { left: 0; + padding-top: 25px; + + & span { + margin-bottom: 3px; + transition: transform ease-in-out 500ms; + } + + &:hover { + & span { + transform: scale(1.1); + transform-origin: 100%; + } + } +} + +.unlockedSecured { + left: 0; + padding-top: 30px; } .network { diff --git a/src/components/savedAccounts/savedAccounts.js b/src/components/savedAccounts/savedAccounts.js index 621aa12d67..389f0ee7b3 100644 --- a/src/components/savedAccounts/savedAccounts.js +++ b/src/components/savedAccounts/savedAccounts.js @@ -27,6 +27,7 @@ class SavedAccounts extends React.Component { super(); this.state = { + isSecureAppears: {}, }; } @@ -57,6 +58,24 @@ class SavedAccounts extends React.Component { e.stopPropagation(); } + handleRemovePassphrase(account, e) { + e.stopPropagation(); + + const uniqueID = `${account.network}${account.publicKey}`; + const { savedAccounts } = this.props; + const savedActiveAccount = savedAccounts.find(acc => `${acc.network}${acc.passphrase}` === `${account.network}${account.passphrase}`); + if (savedActiveAccount) { + this.props.removePassphrase(account); + } + + this.props.removeSavedAccountPassphrase(account); + + this.setState({ isSecureAppears: { ...this.state.isSecureAppears, [uniqueID]: true } }); + setTimeout(() => { + this.setState({ isSecureAppears: { ...this.state.isSecureAppears, [uniqueID]: false } }); + }, 5000); + } + render() { const { closeDialog, @@ -102,9 +121,16 @@ class SavedAccounts extends React.Component { key={account.publicKey + account.network} onClick={ switchAccount.bind(null, account)} > {(account.passphrase ? - + - {t('Unlocked')} + {t('Lock ID')} + : + null)} + {(this.state.isSecureAppears[`${account.network}${account.publicKey}`] ? + + {t('Your ID is now secured!')} : null)} {(account.network !== networks.mainnet.code ? diff --git a/src/components/savedAccounts/savedAccounts.test.js b/src/components/savedAccounts/savedAccounts.test.js index f265ff9b98..91f4be5d18 100644 --- a/src/components/savedAccounts/savedAccounts.test.js +++ b/src/components/savedAccounts/savedAccounts.test.js @@ -2,11 +2,12 @@ import React from 'react'; import { expect } from 'chai'; import { MemoryRouter as Router } from 'react-router-dom'; import { mount } from 'enzyme'; -import { spy } from 'sinon'; +import { spy, useFakeTimers } from 'sinon'; import configureStore from 'redux-mock-store'; import PropTypes from 'prop-types'; import i18n from '../../i18n'; import networks from '../../constants/networks'; +import accounts from '../../../test/constants/accounts'; import SavedAccounts from './savedAccounts'; import routes from '../../constants/routes'; @@ -26,6 +27,7 @@ describe('SavedAccounts', () => { { publicKey: 'hab9d261ea050b9e326d7e11587eccc343a20e64e29d8781b50fd06683cacc88', network: networks.mainnet.code, + passphrase: accounts.genesis.passphrase, }, { network: networks.customNode.code, @@ -39,6 +41,7 @@ describe('SavedAccounts', () => { { network: networks.testnet.code, publicKey: activeAccount.publicKey, + passphrase: accounts.genesis.passphrase, }, ]; @@ -46,6 +49,8 @@ describe('SavedAccounts', () => { closeDialog: () => {}, accountRemoved: spy(), accountSwitched: spy(), + removePassphrase: spy(), + removeSavedAccountPassphrase: spy(), networkOptions: { code: networks.mainnet.code, }, @@ -62,6 +67,7 @@ describe('SavedAccounts', () => { account: { balance: 100e8, }, + isSecureAppears: false, }); wrapper = mountWithRouter(, { context: { store, i18n }, @@ -87,12 +93,36 @@ describe('SavedAccounts', () => { expect(props.accountRemoved).to.have.been.calledWith(savedAccounts[1]); }); + it('should call props.removePassphrase and props.removeSavedAccountPassphrase on "Lock ID" click when account is in saved', () => { + expect(wrapper.find('strong.unlocked')).to.have.lengthOf(2); + wrapper.find('strong.unlocked').at(1).simulate('click'); + expect(props.removePassphrase).to.have.been.calledWith(savedAccounts[3]); + expect(props.removeSavedAccountPassphrase).to.have.been.calledWith(savedAccounts[3]); + }); + it('should call props.accountSwitched on the "saved account card" click', () => { wrapper.find('.saved-account-card').at(1).simulate('click'); expect(props.accountSwitched).to.have.been.calledWith(savedAccounts[1]); expect(props.history.push).to.have.been.calledWith(`${routes.main.path}${routes.dashboard.path}`); }); + it('should check if "Your ID is now secured!" disapears after 5sec', () => { + const clock = useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'Date'], + }); + + expect(wrapper.find('strong.unlockedSecured')).to.have.lengthOf(0); + wrapper.find('strong.unlocked').at(1).simulate('click'); + clock.tick(2000); + expect(wrapper.find('strong.unlockedSecured')).to.have.lengthOf(1); + + clock.tick(7000); + wrapper.update(); + expect(wrapper.find('strong.unlockedSecured')).to.have.lengthOf(0); + + clock.restore(); + }); + it('should not call props.accountSwitched on the "saved account card" click if in "edit" mode', () => { wrapper.find('button.edit-button').simulate('click'); wrapper.find('.saved-account-card').at(0).simulate('click'); diff --git a/src/constants/actions.js b/src/constants/actions.js index b8e676284f..7ec7060048 100644 --- a/src/constants/actions.js +++ b/src/constants/actions.js @@ -43,6 +43,7 @@ const actionTypes = { removePassphrase: 'REMOVE_PASSPHRASE', settingsUpdated: 'SETTINGS_UPDATED', settingsReset: 'SETTINGS_RESET', + removeSavedAccountPassphrase: 'REMOVE_SAVED_ACCOUNT_PASSPHRASE', }; export default actionTypes; diff --git a/src/store/middlewares/login.js b/src/store/middlewares/login.js index ac5f5df31c..f2b1c5f648 100644 --- a/src/store/middlewares/login.js +++ b/src/store/middlewares/login.js @@ -14,13 +14,14 @@ const loginMiddleware = store => next => (action) => { next(Object.assign({}, action, { data: action.data.activePeer })); - const { passphrase } = action.data; + const { passphrase, activePeer: { options: { code } } } = action.data; const publicKey = passphrase ? extractPublicKey(passphrase) : action.data.publicKey; const address = extractAddress(publicKey); const accountBasics = { passphrase, publicKey, address, + network: code, }; const { activePeer } = action.data; diff --git a/src/store/middlewares/login.test.js b/src/store/middlewares/login.test.js index f9fc24dbd6..f0b9545225 100644 --- a/src/store/middlewares/login.test.js +++ b/src/store/middlewares/login.test.js @@ -5,6 +5,7 @@ import middleware from './login'; import actionTypes from '../../constants/actions'; import * as accountApi from '../../utils/api/account'; import * as delegateApi from '../../utils/api/delegate'; +import networks from '../../constants/networks'; describe('Login middleware', () => { let store; @@ -12,7 +13,7 @@ describe('Login middleware', () => { let accountApiMock; let delegateApiMock; const { passphrase } = accounts.genesis; - const activePeer = {}; + const activePeer = { options: { code: networks.mainnet } }; const activePeerSetAction = { type: actionTypes.activePeerSet, data: { diff --git a/src/store/reducers/savedAccounts.js b/src/store/reducers/savedAccounts.js index 2cdf77249b..38825612fd 100644 --- a/src/store/reducers/savedAccounts.js +++ b/src/store/reducers/savedAccounts.js @@ -59,11 +59,13 @@ const savedAccounts = (state = { accounts: [] }, action) => { !(account.publicKey === action.data.publicKey && account.network === action.data.network)), }; - case actionTypes.removePassphrase: + case actionTypes.removeSavedAccountPassphrase: return { ...state, accounts: state.accounts.map((account) => { - delete account.passphrase; + if (!action.data || (`${action.data.network}${action.data.passphrase}` === `${account.network}${account.passphrase}`)) { + delete account.passphrase; + } return account; }), }; diff --git a/src/store/reducers/savedAccounts.test.js b/src/store/reducers/savedAccounts.test.js index 224dedc54a..62ec86b321 100644 --- a/src/store/reducers/savedAccounts.test.js +++ b/src/store/reducers/savedAccounts.test.js @@ -71,10 +71,11 @@ describe('Reducer: savedAccounts(state, action)', () => { expect(changedState).to.deep.equal({ accounts: [account2] }); }); - it('should return array same accounts without passphrase if action.type = actionTypes.removePassphrase', () => { + it('should return array same accounts without passphrase if action.type = actionTypes.removeSavedAccountPassphrase', () => { const state = { accounts: [account, account2] }; const action = { - type: actionTypes.removePassphrase, + type: actionTypes.removeSavedAccountPassphrase, + data: account2, }; const changedState = savedAccounts(state, action); const account2WithoutPassphrase = { ...account2 }; diff --git a/test/integration/accountSwitch.test.js b/test/integration/accountSwitch.test.js index 10ab3827d9..565b0b6757 100644 --- a/test/integration/accountSwitch.test.js +++ b/test/integration/accountSwitch.test.js @@ -34,6 +34,7 @@ describe('@integration: Account switch', () => { publicKey: accounts.delegate.publicKey, address: 'http://localhost:8080', balance: accounts.delegate.balance, + passphrase: accounts.genesis.passphrase, }, { network: networks.mainnet.code, publicKey: accounts['empty account'].publicKey, @@ -100,6 +101,13 @@ describe('@integration: Account switch', () => { step('Then I should see 2 instances of "saved account card"', () => helper.shouldSeeCountInstancesOf(2, '.saved-account-card')); }); + describe('Scenario: should allow to "Lock ID" account', () => { + step('Given I\'m on "account switcher" with accounts: "genesis,delegate,empty account"', setupStep); + step('Then I should see 1 instance of "Lock ID"', () => helper.shouldSeeCountInstancesOf(1, 'strong.unlocked')); + step('When I click "Lock ID"', () => helper.clickOnElement('strong.unlocked')); + step('Then I should see 0 instances of "Lock ID"', () => helper.shouldSeeCountInstancesOf(0, 'strong.unlocked')); + }); + describe('Scenario: should allow to switch account', () => { step('Given I\'m on "account switcher" with accounts: "genesis,delegate,empty account"', setupStep); step('When I click "saved account card"', () => helper.clickOnElement('.saved-account-card')); diff --git a/test/integration/login.test.js b/test/integration/login.test.js index af508fce2a..fda047dd7c 100644 --- a/test/integration/login.test.js +++ b/test/integration/login.test.js @@ -19,6 +19,7 @@ import { activePeerSet } from '../../src/actions/peers'; import * as toasterActions from '../../src/actions/toaster'; import Login from './../../src/components/login'; import accounts from '../constants/accounts'; +import networks from './../../src/constants/networks'; import GenericStepDefinition from '../utils/genericStepDefinition'; describe('@integration: Login', () => { @@ -76,6 +77,7 @@ describe('@integration: Login', () => { secondSignature: 0, secondPublicKey: null, multisignatures: [], + network: networks.mainnet.code, u_multisignatures: [], }); delegateAPIStub = stub(delegateAPI, 'getDelegate').returnsPromise().rejects(); @@ -97,7 +99,7 @@ describe('@integration: Login', () => { checkIfInRoute() { expect(this.store.getState().account).to.have.all.keys('passphrase', 'publicKey', 'address', 'delegate', 'isDelegate', 'expireTime', 'u_multisignatures', 'multisignatures', 'unconfirmedBalance', - 'secondSignature', 'secondPublicKey', 'balance', 'unconfirmedSignature'); + 'secondSignature', 'secondPublicKey', 'balance', 'unconfirmedSignature', 'network'); restoreStubs(); }