From 73222b962df823e5301a02b397fef80fcdca5a11 Mon Sep 17 00:00:00 2001 From: Matt Huggins Date: Tue, 23 Jan 2018 14:57:01 -0600 Subject: [PATCH] Replaced authentication actions/reducers with new actions architecture (#586) * Add failed screen when data can't be fetched * Replaced module auth functions with actions * Fixed withData to account for data not being loaded yet * Added redirects when user logs in or out * Added error notifications to login actions * Fixed showing back to back error notifications * Added wallet reload when network changes --- __tests__/components/App.test.js | 5 +- __tests__/components/LoginNep2.test.js | 63 +--- __tests__/components/Logout.test.js | 9 +- __tests__/components/WalletInfo.test.js | 12 +- .../__snapshots__/LoginNep2.test.js.snap | 2 +- .../__snapshots__/Logout.test.js.snap | 13 +- .../TransactionHistory.test.js.snap | 3 +- .../__snapshots__/WalletInfo.test.js.snap | 3 +- __tests__/modules/account.test.js | 35 --- __tests__/modules/dashboard.test.js | 5 - __tests__/store/reducers.test.js | 5 +- app/actions/accountActions.js | 69 +++++ app/actions/accountsActions.js | 29 ++ app/actions/appActions.js | 3 +- app/actions/ledgerActions.js | 23 ++ app/actions/settingsActions.js | 6 +- app/containers/App/Failed.js | 11 + app/containers/App/Failed.scss | 3 + app/containers/App/Header/Header.jsx | 14 +- app/containers/App/Header/Logout/Logout.jsx | 20 +- app/containers/App/Header/Logout/index.js | 15 +- app/containers/App/Header/index.js | 20 +- app/containers/App/index.js | 17 +- app/containers/Dashboard/Dashboard.jsx | 7 + app/containers/Dashboard/index.js | 7 +- app/containers/DisplayWalletAccounts/index.js | 1 - .../LoginLedgerNanoS/LoginLedgerNanoS.jsx | 104 ++++--- app/containers/LoginLedgerNanoS/index.js | 42 +-- .../LoginLocalStorage/LoginLocalStorage.jsx | 34 ++- app/containers/LoginLocalStorage/index.js | 28 +- app/containers/LoginNep2/index.js | 21 +- .../LoginPrivateKey/LoginPrivateKey.jsx | 7 +- app/containers/LoginPrivateKey/index.js | 20 +- app/containers/Settings/Settings.jsx | 9 - app/containers/Settings/index.js | 19 +- app/containers/TransactionHistory/index.js | 6 +- app/containers/WalletInfo/index.js | 4 +- app/core/account.js | 26 ++ app/core/constants.js | 3 - app/core/deprecated.js | 21 ++ .../alreadyLoadedStrategy.js | 2 +- .../api/progressStrategies/defaultStrategy.js | 2 +- app/hocs/api/withData.js | 6 +- app/hocs/api/withError.js | 47 +++ app/hocs/api/withProgressProp.js | 18 +- app/hocs/auth/withLoginRedirect.js | 9 + app/hocs/auth/withLogoutRedirect.js | 9 + app/hocs/auth/withRedirect.js | 49 +++ app/hocs/withAccountData.js | 25 ++ app/hocs/withFailureNotification.js | 57 ++++ app/ledger/connect.js | 24 ++ app/ledger/getDeviceInfo.js | 12 + app/ledger/getPublicKey.js | 25 ++ app/modules/account.js | 289 ------------------ app/modules/claim.js | 10 +- app/modules/dashboard.js | 13 - app/modules/index.js | 8 +- app/modules/sale.js | 16 +- app/modules/transactions.js | 7 +- app/modules/wallet.js | 5 +- app/values/api.js | 4 +- 61 files changed, 710 insertions(+), 671 deletions(-) delete mode 100644 __tests__/modules/account.test.js delete mode 100644 __tests__/modules/dashboard.test.js create mode 100644 app/actions/accountActions.js create mode 100644 app/actions/accountsActions.js create mode 100644 app/actions/ledgerActions.js create mode 100644 app/containers/App/Failed.js create mode 100644 app/containers/App/Failed.scss create mode 100644 app/core/account.js create mode 100644 app/hocs/api/withError.js create mode 100644 app/hocs/auth/withLoginRedirect.js create mode 100644 app/hocs/auth/withLogoutRedirect.js create mode 100644 app/hocs/auth/withRedirect.js create mode 100644 app/hocs/withAccountData.js create mode 100644 app/hocs/withFailureNotification.js create mode 100644 app/ledger/connect.js create mode 100644 app/ledger/getDeviceInfo.js create mode 100644 app/ledger/getPublicKey.js delete mode 100644 app/modules/account.js delete mode 100644 app/modules/dashboard.js diff --git a/__tests__/components/App.test.js b/__tests__/components/App.test.js index 7a1b84aa7..4700687a6 100644 --- a/__tests__/components/App.test.js +++ b/__tests__/components/App.test.js @@ -1,5 +1,6 @@ import React from 'react' import { Provider } from 'react-redux' +import { MemoryRouter } from 'react-router-dom' import thunk from 'redux-thunk' import storage from 'electron-json-storage' import configureStore from 'redux-mock-store' @@ -54,7 +55,9 @@ const setup = (state, shallowRender = true) => { } else { wrapper = mount( - + + + ) } diff --git a/__tests__/components/LoginNep2.test.js b/__tests__/components/LoginNep2.test.js index 73a1e44f4..4f0f246c8 100644 --- a/__tests__/components/LoginNep2.test.js +++ b/__tests__/components/LoginNep2.test.js @@ -7,9 +7,6 @@ import { shallow, mount } from 'enzyme' import { createMemoryHistory } from 'history' import LoginNep2 from '../../app/containers/LoginNep2' -import { SHOW_NOTIFICATION, HIDE_NOTIFICATIONS, HIDE_NOTIFICATION, DEFAULT_POSITION } from '../../app/modules/notifications' -import { LOGIN } from '../../app/modules/account' -import { NOTIFICATION_LEVELS } from '../../app/core/constants' jest.useFakeTimers() jest.mock('neon-js') @@ -60,6 +57,7 @@ describe('LoginNep2', () => { expect(keyField.props.type).toEqual('password') done() }) + test('the login button is working correctly with no passphrase or wif', (done) => { const { wrapper, store } = setup(false) @@ -70,35 +68,8 @@ describe('LoginNep2', () => { done() }) }) - // test('the login button is working correctly with only a short passphrase', (done) => { - // const { wrapper, store } = setup(false) - - // const passwordField = wrapper.find('input[placeholder="Enter your passphrase here"]') - // passwordField.instance().value = 'T' - // passwordField.simulate('change') - - // const keyField = wrapper.find('input[placeholder="Enter your encrypted key here"]') - // keyField.instance().value = '6PYUGtvXiT5TBetgWf77QyAFidQj61V8FJeFBFtYttmsSxcbmP4vCFRCWu' - // keyField.simulate('change') - - // wrapper.find('#loginButton').simulate('click') - - // Promise.resolve('pause').then(() => { - // jest.runAllTimers() - // const actions = store.getActions() - // expect(actions.length).toEqual(2) - // expect(actions[0]).toEqual({ - // type: SEND_TRANSACTION, - // success: false, - // message: 'Passphrase too short' - // }) - // expect(actions[1]).toEqual({ - // type: CLEAR_TRANSACTION - // }) - // done() - // }) - // }) - test('the login button is working correctly with key and passphrase', (done) => { + + test('the login button is working correctly with key and passphrase', () => { const { wrapper, store } = setup(false) const passwordField = wrapper.find('input[placeholder="Enter your passphrase here"]') @@ -110,31 +81,9 @@ describe('LoginNep2', () => { keyField.simulate('change') wrapper.find('#loginButton').first().simulate('submit') - Promise.resolve('Pause').then().then() - jest.runAllTimers() + const actions = store.getActions() - expect(actions.length).toEqual(4) - expect(actions[0]).toEqual({ - type: HIDE_NOTIFICATIONS, - payload: { - dismissible: true, - position: DEFAULT_POSITION - } - }) - expect(actions[1]).toEqual({ - type: SHOW_NOTIFICATION, - payload: expect.objectContaining({ - message: 'Decrypting encoded key...', - level: NOTIFICATION_LEVELS.INFO - }) - }) - expect(actions[2]).toEqual({ - type: HIDE_NOTIFICATION, - payload: expect.objectContaining({ - id: 'notification_1' - }) - }) - expect(actions[3]).toHaveProperty('type', LOGIN) - done() + expect(actions.length).toEqual(1) + expect(actions[0].type).toEqual('ACCOUNT/REQ/REQUEST') }) }) diff --git a/__tests__/components/Logout.test.js b/__tests__/components/Logout.test.js index a2da21bc5..d73fc83a2 100644 --- a/__tests__/components/Logout.test.js +++ b/__tests__/components/Logout.test.js @@ -1,19 +1,20 @@ import React from 'react' import { shallow } from 'enzyme' -import Logout from '../../app/containers/App/Header/Logout' +import Logout from '../../app/containers/App/Header/Logout/Logout' describe('Logout', () => { const logout = jest.fn() + test('should render without crashing', () => { - const wrapper = shallow() + const wrapper = shallow() expect(wrapper).toMatchSnapshot() }) test('should dispatch logout action when clicked', () => { - const wrapper = shallow() + const wrapper = shallow() expect(logout.mock.calls.length).toEqual(0) - wrapper.find('.logout').simulate('click') + wrapper.find('#logout').simulate('click') expect(logout.mock.calls.length).toEqual(1) }) }) diff --git a/__tests__/components/WalletInfo.test.js b/__tests__/components/WalletInfo.test.js index db2a0e45f..4830bfb47 100644 --- a/__tests__/components/WalletInfo.test.js +++ b/__tests__/components/WalletInfo.test.js @@ -54,6 +54,13 @@ const initialState = { state: LOADED, data: MAIN_NETWORK_ID }, + ACCOUNT: { + batch: false, + state: LOADED, + data: { + address: 'ANqUrhv99rwCiFTL6N1An9NH5UVkPYxTuw' + } + }, SETTINGS: { batch: false, state: LOADED, @@ -70,11 +77,6 @@ const initialState = { } } }, - account: { - address: 'ANqUrhv99rwCiFTL6N1An9NH5UVkPYxTuw' - }, - metadata: { - }, wallet: { NEO: '100001', GAS: '1000.0001601', diff --git a/__tests__/components/__snapshots__/LoginNep2.test.js.snap b/__tests__/components/__snapshots__/LoginNep2.test.js.snap index 40a683ced..25bbf9daf 100644 --- a/__tests__/components/__snapshots__/LoginNep2.test.js.snap +++ b/__tests__/components/__snapshots__/LoginNep2.test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`LoginNep2 renders without crashing 1`] = ` - - - - - - + + `; diff --git a/__tests__/components/__snapshots__/TransactionHistory.test.js.snap b/__tests__/components/__snapshots__/TransactionHistory.test.js.snap index 8b17cb365..ba3722f18 100644 --- a/__tests__/components/__snapshots__/TransactionHistory.test.js.snap +++ b/__tests__/components/__snapshots__/TransactionHistory.test.js.snap @@ -1,8 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`TransactionHistory renders without crashing 1`] = ` - { - test('login into account with no address adds address to NEP-6 wallet', (done) => { - storage.get = jest.fn((key, callback) => { - if (key === 'userWallet') { - const testWallet = {...DEFAULT_WALLET} - const testAccount = convertOldWalletAccount( - 'my label', - '6PYUGtvXiT5TBetgWf77QyAFidQj61V8FJeFBFtYttmsSxcbmP4vCFRCWu', - false - ) - - testWallet.accounts = [ testAccount ] - callback(null, testWallet) - } - }) - - storage.set = jest.fn((key, wallet) => { - if (key === 'userWallet') { - expect(wallet.accounts[0].address).toEqual('AM22coFfbe9N6omgL9ucFBLkeaMNg9TEyL') - done() - } - }) - - loginNep2('Th!s1$@FakePassphrase', '6PYUGtvXiT5TBetgWf77QyAFidQj61V8FJeFBFtYttmsSxcbmP4vCFRCWu', [])(() => {}) - jest.runAllTimers() - }) -}) diff --git a/__tests__/modules/dashboard.test.js b/__tests__/modules/dashboard.test.js deleted file mode 100644 index 47b7075ce..000000000 --- a/__tests__/modules/dashboard.test.js +++ /dev/null @@ -1,5 +0,0 @@ -describe('dashboard module tests', () => { - it('returns true', () => { - expect(true).toEqual(true) - }) -}) diff --git a/__tests__/store/reducers.test.js b/__tests__/store/reducers.test.js index 1c487538f..c41d24a28 100644 --- a/__tests__/store/reducers.test.js +++ b/__tests__/store/reducers.test.js @@ -4,16 +4,13 @@ describe('root reducer', () => { it('should combine all reducers', () => { expect(reducer({}, { type: '@@INIT' })).toEqual({ api: expect.any(Object), - account: expect.any(Object), addressBook: expect.any(Object), generateWallet: expect.any(Object), wallet: expect.any(Object), transactions: expect.any(Object), - dashboard: expect.any(Object), notifications: expect.any(Object), claim: expect.any(Object), - modal: expect.any(Object), - sale: expect.any(Object) + modal: expect.any(Object) }) }) }) diff --git a/app/actions/accountActions.js b/app/actions/accountActions.js new file mode 100644 index 000000000..ca933c504 --- /dev/null +++ b/app/actions/accountActions.js @@ -0,0 +1,69 @@ +// @flow +import { wallet } from 'neon-js' +import { noop } from 'lodash' + +import createRequestActions from '../util/api/createRequestActions' +import { upgradeNEP6AddAddresses } from '../core/account' +import { validatePassphraseLength } from '../core/wallet' + +type WifLoginProps = { + wif: string +} + +type LedgerLoginProps = { + publicKey: string, + signingFunction: Function +} + +type Nep2LoginProps = { + passphrase: string, + encryptedWIF: string +} + +type AccountType = ?{ + address: string, + wif?: string, + publicKey?: string, + signingFunction?: Function, + isHardwareLogin: boolean +} + +export const ID = 'ACCOUNT' + +export const wifLoginActions = createRequestActions(ID, ({ wif }: WifLoginProps) => (state: Object): AccountType => { + const account = new wallet.Account(wif) + + return { wif, address: account.address, isHardwareLogin: false } +}) + +export const nep2LoginActions = createRequestActions(ID, ({ passphrase, encryptedWIF }: Nep2LoginProps) => async (state: Object): Promise => { + if (!validatePassphraseLength(passphrase)) { + throw new Error('Passphrase too short') + } + + if (!wallet.isNEP2(encryptedWIF)) { + throw new Error('That is not a valid encrypted key') + } + + const wif = wallet.decrypt(encryptedWIF, passphrase) + const account = new wallet.Account(wif) + + await upgradeNEP6AddAddresses(encryptedWIF, wif) + + return { wif, address: account.address, isHardwareLogin: false } +}) + +export const ledgerLoginActions = createRequestActions(ID, ({ publicKey, signingFunction }: LedgerLoginProps) => (state: Object): AccountType => { + const publicKeyEncoded = wallet.getPublicKeyEncoded(publicKey) + const account = new wallet.Account(publicKeyEncoded) + + return { address: account.address, publicKey, signingFunction, isHardwareLogin: true } +}) + +export const logoutActions = createRequestActions(ID, () => (state: Object): AccountType => { + return null +}) + +// TODO: Better way to expose action data than to make a faux function? One idea is to change +// `withData` to accept the `ID` exported from this file instead of a generated action. +export default createRequestActions(ID, () => (state: Object) => noop) diff --git a/app/actions/accountsActions.js b/app/actions/accountsActions.js new file mode 100644 index 000000000..656ed9aff --- /dev/null +++ b/app/actions/accountsActions.js @@ -0,0 +1,29 @@ +// @flow +import createRequestActions from '../util/api/createRequestActions' +import { getStorage, setStorage } from '../core/storage' +import { DEFAULT_WALLET } from '../core/constants' + +type Props = { + networkId: string +} + +const STORAGE_KEY = 'userWallet' + +const getWallet = async (): Promise => { + return await getStorage(STORAGE_KEY) || DEFAULT_WALLET +} + +export const ID = 'ACCOUNTS' + +export const updateAccountsActions = createRequestActions(ID, (accounts) => async (state: Object): Promise => { + const userWallet = await getWallet() + const newWallet = { ...userWallet, accounts } + await setStorage(STORAGE_KEY, newWallet) + + return newWallet +}) + +export default createRequestActions(ID, ({ networkId }: Props = {}) => async (state: Object): Promise => { + const userWallet = await getWallet() + return userWallet.accounts +}) diff --git a/app/actions/appActions.js b/app/actions/appActions.js index 19d64ebfa..b283d4c2f 100644 --- a/app/actions/appActions.js +++ b/app/actions/appActions.js @@ -1,6 +1,6 @@ // @flow import createBatchActions from '../util/api/createBatchActions' - +import accountsActions from './accountsActions' import blockHeightActions from './blockHeightActions' import pricesActions from './pricesActions' import settingsActions from './settingsActions' @@ -8,6 +8,7 @@ import settingsActions from './settingsActions' export const ID = 'APP' export default createBatchActions(ID, { + accounts: accountsActions, blockHeight: blockHeightActions, prices: pricesActions, settings: settingsActions diff --git a/app/actions/ledgerActions.js b/app/actions/ledgerActions.js new file mode 100644 index 000000000..cf5527c63 --- /dev/null +++ b/app/actions/ledgerActions.js @@ -0,0 +1,23 @@ +// @flow +import createBatchActions from '../util/api/createBatchActions' +import createRequestActions from '../util/api/createRequestActions' +import getDeviceInfo from '../ledger/getDeviceInfo' +import getPublicKey from '../ledger/getPublicKey' + +const DEVICE_ID = 'LEDGER_DEVICE' +const PUBLIC_KEY_ID = 'LEDGER_PUBLIC_KEY' + +export const ID = 'LEDGER' + +const deviceInfoActions = createRequestActions(DEVICE_ID, () => async (state: Object) => { + return getDeviceInfo() +}) + +const publicKeyActions = createRequestActions(PUBLIC_KEY_ID, () => async (state: Object) => { + return getPublicKey() +}) + +export default createBatchActions(ID, { + deviceInfo: deviceInfoActions, + publicKey: publicKeyActions +}) diff --git a/app/actions/settingsActions.js b/app/actions/settingsActions.js index ef3586792..268dddc99 100644 --- a/app/actions/settingsActions.js +++ b/app/actions/settingsActions.js @@ -12,6 +12,8 @@ type Settings = { tokens?: Array } +const STORAGE_KEY = 'settings' + const DEFAULT_SETTINGS: Settings = { currency: DEFAULT_CURRENCY_CODE, blockExplorer: EXPLORERS.NEO_TRACKER, @@ -20,7 +22,7 @@ const DEFAULT_SETTINGS: Settings = { const getSettings = async (): Promise => ({ ...DEFAULT_SETTINGS, - ...await getStorage('settings') + ...await getStorage(STORAGE_KEY) }) export const ID = 'SETTINGS' @@ -28,7 +30,7 @@ export const ID = 'SETTINGS' export const updateSettingsActions = createRequestActions(ID, (values: Settings = {}) => async (state: Object): Promise => { const settings = await getSettings() const newSettings = { ...settings, ...values } - await setStorage('settings', newSettings) + await setStorage(STORAGE_KEY, newSettings) return newSettings }) diff --git a/app/containers/App/Failed.js b/app/containers/App/Failed.js new file mode 100644 index 000000000..8b135861c --- /dev/null +++ b/app/containers/App/Failed.js @@ -0,0 +1,11 @@ +import React from 'react' + +import styles from './Failed.scss' + +export default () => { + return ( +
+ Failed to load. +
+ ) +} diff --git a/app/containers/App/Failed.scss b/app/containers/App/Failed.scss new file mode 100644 index 000000000..90e090add --- /dev/null +++ b/app/containers/App/Failed.scss @@ -0,0 +1,3 @@ +.failed { + // TODO +} diff --git a/app/containers/App/Header/Header.jsx b/app/containers/App/Header/Header.jsx index 022a92164..09947548c 100644 --- a/app/containers/App/Header/Header.jsx +++ b/app/containers/App/Header/Header.jsx @@ -16,23 +16,21 @@ import logo from '../../../images/neon-logo2.png' const Logo = () =>
type Props = { + address: string, neoPrice: number, gasPrice: number, - currencyCode: string, - isLoggedIn: boolean, - logout: () => any + currencyCode: string } const Header = ({ - logout, + address, neoPrice, gasPrice, - currencyCode, - isLoggedIn + currencyCode }: Props) => (
- {isLoggedIn && + {address &&
- +
}
diff --git a/app/containers/App/Header/Logout/Logout.jsx b/app/containers/App/Header/Logout/Logout.jsx index fb2b633d8..b9cc9f496 100644 --- a/app/containers/App/Header/Logout/Logout.jsx +++ b/app/containers/App/Header/Logout/Logout.jsx @@ -1,24 +1,20 @@ // @flow import React from 'react' -import { Link } from 'react-router-dom' - import Power from 'react-icons/lib/md/power-settings-new' -import Tooltip from '../../../../components/Tooltip' -import { ROUTES } from '../../../../core/constants' +import Tooltip from '../../../../components/Tooltip' import styles from './Logout.scss' type Props = { - onClick: Function + logout: Function } -const Logout = ({ onClick }: Props) => -
- - - - - +const Logout = ({ logout }: Props) => ( +
+ + +
+) export default Logout diff --git a/app/containers/App/Header/Logout/index.js b/app/containers/App/Header/Logout/index.js index a8198b7b4..e06352c3e 100644 --- a/app/containers/App/Header/Logout/index.js +++ b/app/containers/App/Header/Logout/index.js @@ -1 +1,14 @@ -export { default } from './Logout' +// @flow +import Logout from './Logout' +import withActions from '../../../../hocs/api/withActions' +import { logoutActions } from '../../../../actions/accountActions' + +type Props = { + logout: Function +} + +const mapActionsToProps = (actions): Props => ({ + logout: () => actions.request() +}) + +export default withActions(logoutActions, mapActionsToProps)(Logout) diff --git a/app/containers/App/Header/index.js b/app/containers/App/Header/index.js index 18a6a4c0c..022cf55ff 100644 --- a/app/containers/App/Header/index.js +++ b/app/containers/App/Header/index.js @@ -1,33 +1,19 @@ // @flow -import { connect, type MapStateToProps } from 'react-redux' -import { bindActionCreators } from 'redux' import { compose } from 'recompose' +import Header from './Header' import withData from '../../../hocs/api/withData' +import withAccountData from '../../../hocs/withAccountData' import withCurrencyData from '../../../hocs/withCurrencyData' import pricesActions from '../../../actions/pricesActions' -import { logout, getAddress, getLoggedIn } from '../../../modules/account' - -import Header from './Header' - -const mapStateToProps: MapStateToProps<*, *, *> = (state: Object) => ({ - address: getAddress(state), - isLoggedIn: getLoggedIn(state) -}) const mapPricesDataToProps = ({ NEO, GAS }) => ({ neoPrice: NEO, gasPrice: GAS }) -const actionCreators = { - logout -} - -const mapDispatchToProps = dispatch => bindActionCreators(actionCreators, dispatch) - export default compose( - connect(mapStateToProps, mapDispatchToProps), withData(pricesActions, mapPricesDataToProps), + withAccountData(), withCurrencyData('currencyCode') )(Header) diff --git a/app/containers/App/index.js b/app/containers/App/index.js index 0ca884483..9e8d8e697 100644 --- a/app/containers/App/index.js +++ b/app/containers/App/index.js @@ -7,16 +7,19 @@ import withData from '../../hocs/api/withData' import withFetch from '../../hocs/api/withFetch' import withReload from '../../hocs/api/withReload' import withProgressComponents from '../../hocs/api/withProgressComponents' +import withLoginRedirect from '../../hocs/auth/withLoginRedirect' +import withLogoutRedirect from '../../hocs/auth/withLogoutRedirect' import appActions from '../../actions/appActions' import alreadyLoaded from '../../hocs/api/progressStrategies/alreadyLoadedStrategy' import withNetworkData from '../../hocs/withNetworkData' import networkActions from '../../actions/networkActions' import { checkVersion } from '../../modules/metadata' import { showErrorNotification } from '../../modules/notifications' -import { LOADING } from '../../values/state' +import { LOADING, FAILED } from '../../values/state' import App from './App' import Loading from './Loading' +import Failed from './Failed' const actionCreators = { checkVersion, @@ -36,7 +39,8 @@ export default compose( withFetch(networkActions), withNetworkData(), withProgressComponents(networkActions, { - [LOADING]: Loading + [LOADING]: Loading, + [FAILED]: Failed }, { strategy: alreadyLoaded }), @@ -46,8 +50,13 @@ export default compose( withData(appActions, mapAppDataToProps), withReload(appActions, ['networkId']), withProgressComponents(appActions, { - [LOADING]: Loading + [LOADING]: Loading, + [FAILED]: Failed }, { strategy: alreadyLoaded - }) + }), + + // Navigate to the home or dashboard when the user logs in or out. + withLoginRedirect, + withLogoutRedirect )(App) diff --git a/app/containers/Dashboard/Dashboard.jsx b/app/containers/Dashboard/Dashboard.jsx index a0f5d6c18..35ca16ef7 100644 --- a/app/containers/Dashboard/Dashboard.jsx +++ b/app/containers/Dashboard/Dashboard.jsx @@ -25,6 +25,7 @@ type Props = { GAS: string, tokenBalances: Array, loaded: boolean, + networkId: string, loadWalletData: Function, } @@ -44,6 +45,12 @@ export default class Dashboard extends Component { clearInterval(walletDataInterval) } + componentWillReceiveProps (nextProps: Props) { + if (this.props.networkId !== nextProps.networkId) { + this.props.loadWalletData() + } + } + render () { const { showModal, diff --git a/app/containers/Dashboard/index.js b/app/containers/Dashboard/index.js index 9ab5365ce..8dbcacd21 100644 --- a/app/containers/Dashboard/index.js +++ b/app/containers/Dashboard/index.js @@ -4,7 +4,7 @@ import { bindActionCreators } from 'redux' import { compose } from 'recompose' import withNetworkData from '../../hocs/withNetworkData' -import { logout, getAddress } from '../../modules/account' +import withAccountData from '../../hocs/withAccountData' import { getNotifications } from '../../modules/notifications' import { getNEO, getGAS, getTokenBalances, getIsLoaded, loadWalletData } from '../../modules/wallet' import { showModal } from '../../modules/modal' @@ -13,7 +13,6 @@ import { sendTransaction } from '../../modules/transactions' import Dashboard from './Dashboard' const mapStateToProps: MapStateToProps<*, *, *> = (state: Object) => ({ - address: getAddress(state), notification: getNotifications(state), NEO: getNEO(state), GAS: getGAS(state), @@ -22,7 +21,6 @@ const mapStateToProps: MapStateToProps<*, *, *> = (state: Object) => ({ }) const actionCreators = { - logout, showModal, sendTransaction, loadWalletData @@ -33,5 +31,6 @@ const mapDispatchToProps = dispatch => export default compose( connect(mapStateToProps, mapDispatchToProps), - withNetworkData() + withNetworkData(), + withAccountData() )(Dashboard) diff --git a/app/containers/DisplayWalletAccounts/index.js b/app/containers/DisplayWalletAccounts/index.js index f82536387..0a4bfd45b 100644 --- a/app/containers/DisplayWalletAccounts/index.js +++ b/app/containers/DisplayWalletAccounts/index.js @@ -10,7 +10,6 @@ import { getEncryptedWIF, getPassphrase } from '../../modules/generateWallet' - import DisplayWalletAccounts from './DisplayWalletAccounts' const mapStateToProps = (state: Object) => ({ diff --git a/app/containers/LoginLedgerNanoS/LoginLedgerNanoS.jsx b/app/containers/LoginLedgerNanoS/LoginLedgerNanoS.jsx index c7c35aaf2..af413de54 100644 --- a/app/containers/LoginLedgerNanoS/LoginLedgerNanoS.jsx +++ b/app/containers/LoginLedgerNanoS/LoginLedgerNanoS.jsx @@ -3,78 +3,82 @@ import React, { Component } from 'react' import HomeButtonLink from '../../components/HomeButtonLink' import Button from '../../components/Button' +import styles from '../../styles/login.scss' -import { ROUTES, FINDING_LEDGER_NOTICE } from '../../core/constants' - -import loginStyles from '../../styles/login.scss' +type DeviceInfo = { + manufacturer: string, + product: string +} type Props = { - ledgerNanoSGetLogin: Function, - ledgerNanoSGetInfoAsync: Function, - hardwareDeviceInfo: string, - hardwarePublicKeyInfo: string, - publicKey: string, - history: Object -} -type State = { - intervalId: any + login: Function, + connect: Function, + deviceInfo: ?DeviceInfo, + publicKey: ?string, + error: ?string } -export default class LoginLedgerNanoS extends Component { - state = { - intervalId: null - } +const POLL_FREQUENCY = 1000 - componentDidMount () { - const { ledgerNanoSGetInfoAsync } = this.props - const intervalId = setInterval(async () => { - await ledgerNanoSGetInfoAsync() - }, 1000) - this.setState({ intervalId }) - } +export default class LoginLedgerNanoS extends Component { + intervalId: ?number - shouldComponentUpdate (nextProps: Props) { - const { publicKey, hardwarePublicKeyInfo, hardwareDeviceInfo } = this.props - if ( - nextProps.publicKey !== publicKey || - nextProps.hardwarePublicKeyInfo !== hardwarePublicKeyInfo || - (nextProps.hardwareDeviceInfo === FINDING_LEDGER_NOTICE && - hardwareDeviceInfo === null) - ) { return true } - return false + componentDidMount () { + this.intervalId = setInterval(this.props.connect, POLL_FREQUENCY) } componentWillUnmount () { - const { intervalId } = this.state - if (intervalId) { - clearInterval(intervalId) - } - } - - onLedgerNanoSChange = () => { - const { ledgerNanoSGetLogin, publicKey, history } = this.props - if (publicKey) { - ledgerNanoSGetLogin() - history.push(ROUTES.DASHBOARD) + if (this.intervalId) { + clearInterval(this.intervalId) } } render () { - const { hardwareDeviceInfo, hardwarePublicKeyInfo, publicKey } = this.props return ( -
-
Login using the Ledger Nano S:
-
+
+
Login using the Ledger Nano S:
+
-
-

{hardwareDeviceInfo}

-

{hardwarePublicKeyInfo}

+ {this.renderDeviceInfo()} + {this.renderStatus()} + {this.renderError()}
) } + + renderDeviceInfo () { + const { deviceInfo } = this.props + + if (deviceInfo) { + return

Found USB ${deviceInfo.manufacturer} ${deviceInfo.product}

+ } else { + return

Looking for USB Devices. Please plugin your device and login.

+ } + } + + renderStatus () { + const { publicKey } = this.props + + if (publicKey) { + return

Success. NEO app found on hardware device. Click button above to login.

+ } + } + + renderError () { + const { error } = this.props + + if (error) { + return

{error}

+ } + } + + canLogin () { + return !!this.props.publicKey + } } diff --git a/app/containers/LoginLedgerNanoS/index.js b/app/containers/LoginLedgerNanoS/index.js index 20633572c..93efeb9a5 100644 --- a/app/containers/LoginLedgerNanoS/index.js +++ b/app/containers/LoginLedgerNanoS/index.js @@ -1,29 +1,29 @@ // @flow -import { connect } from 'react-redux' -import { bindActionCreators } from 'redux' - -import { - ledgerNanoSGetLogin, - ledgerNanoSGetInfoAsync, - getPublicKey, - getHardwareDeviceInfo, - getHardwarePublicKeyInfo -} from '../../modules/account' +import { compose } from 'recompose' import LoginLedgerNanoS from './LoginLedgerNanoS' +import withData from '../../hocs/api/withData' +import withError from '../../hocs/api/withError' +import withFetch from '../../hocs/api/withFetch' +import withActions from '../../hocs/api/withActions' +import ledgerActions from '../../actions/ledgerActions' +import { ledgerLoginActions } from '../../actions/accountActions' -const mapStateToProps = (state: Object) => ({ - publicKey: getPublicKey(state), - hardwareDeviceInfo: getHardwareDeviceInfo(state), - hardwarePublicKeyInfo: getHardwarePublicKeyInfo(state) +const mapLedgerActionsToProps = (actions) => ({ + connect: () => ledgerActions.request() }) -const actionCreators = { - ledgerNanoSGetInfoAsync, - ledgerNanoSGetLogin -} +const mapAccountActionsToProps = (actions) => ({ + login: (publicKey, signingFunction) => ledgerLoginActions.request({ publicKey, signingFunction }) +}) -const mapDispatchToProps = dispatch => - bindActionCreators(actionCreators, dispatch) +const mapLedgerDataToProps = ({ deviceInfo, publicKey }) => ({ deviceInfo, publicKey }) +const mapLedgerErrorToProps = ({ deviceInfo, publicKey }) => ({ error: deviceInfo || publicKey }) -export default connect(mapStateToProps, mapDispatchToProps)(LoginLedgerNanoS) +export default compose( + withFetch(ledgerActions), + withActions(ledgerActions, mapLedgerActionsToProps), + withActions(ledgerLoginActions, mapAccountActionsToProps), + withData(ledgerActions, mapLedgerDataToProps), + withError(ledgerActions, mapLedgerErrorToProps) +)(LoginLedgerNanoS) diff --git a/app/containers/LoginLocalStorage/LoginLocalStorage.jsx b/app/containers/LoginLocalStorage/LoginLocalStorage.jsx index c401789c6..4e4a19d3d 100644 --- a/app/containers/LoginLocalStorage/LoginLocalStorage.jsx +++ b/app/containers/LoginLocalStorage/LoginLocalStorage.jsx @@ -1,6 +1,5 @@ // @flow import React, { Component } from 'react' -import storage from 'electron-json-storage' import { map } from 'lodash' import PasswordField from '../../components/PasswordField' @@ -11,9 +10,7 @@ import styles from './LoginLocalStorage.scss' import loginStyles from '../../styles/login.scss' type Props = { - setAccounts: Function, loginNep2: Function, - history: Object, accounts: Object } @@ -28,30 +25,23 @@ export default class LoginLocalStorage extends Component { encryptedWIF: '' } - componentDidMount () { - const { setAccounts } = this.props - // eslint-disable-next-line - storage.get('userWallet', (error, data) => { - setAccounts(data.accounts) - }) - } - render () { - const { accounts, history, loginNep2 } = this.props + const { accounts } = this.props const { passphrase, encryptedWIF } = this.state - const loginButtonDisabled = Object.keys(accounts).length === 0 || encryptedWIF === '' || passphrase === '' return (
Login using a saved wallet:
-
{ e.preventDefault(); loginNep2(passphrase, encryptedWIF, history) }}> +
{ />
- +
) } + + handleSubmit = (event: Object) => { + const { loginNep2 } = this.props + const { passphrase, encryptedWIF } = this.state + + event.preventDefault() + loginNep2(passphrase, encryptedWIF) + } + + isValid = () => { + return this.state.encryptedWIF !== '' && this.state.passphrase !== '' + } } diff --git a/app/containers/LoginLocalStorage/index.js b/app/containers/LoginLocalStorage/index.js index f910fa04c..869421f92 100644 --- a/app/containers/LoginLocalStorage/index.js +++ b/app/containers/LoginLocalStorage/index.js @@ -1,20 +1,24 @@ // @flow -import { connect } from 'react-redux' -import { bindActionCreators } from 'redux' +import { compose } from 'recompose' -import { setAccounts, getAccounts, loginNep2 } from '../../modules/account' +import withData from '../../hocs/api/withData' +import withActions from '../../hocs/api/withActions' +import withFailureNotification from '../../hocs/withFailureNotification' +import accountsActions from '../../actions/accountsActions' +import { nep2LoginActions } from '../../actions/accountActions' import LoginLocalStorage from './LoginLocalStorage' -const mapStateToProps = (state: Object) => ({ - accounts: getAccounts(state) +const mapAccountsDataToProps = (accounts) => ({ + accounts }) -const actionCreators = { - setAccounts, - loginNep2 -} - -const mapDispatchToProps = dispatch => bindActionCreators(actionCreators, dispatch) +const mapActionsToProps = (actions) => ({ + loginNep2: (passphrase, encryptedWIF) => actions.request({ passphrase, encryptedWIF }) +}) -export default connect(mapStateToProps, mapDispatchToProps)(LoginLocalStorage) +export default compose( + withData(accountsActions, mapAccountsDataToProps), + withActions(nep2LoginActions, mapActionsToProps), + withFailureNotification(nep2LoginActions) +)(LoginLocalStorage) diff --git a/app/containers/LoginNep2/index.js b/app/containers/LoginNep2/index.js index 13e58d545..28a3158be 100644 --- a/app/containers/LoginNep2/index.js +++ b/app/containers/LoginNep2/index.js @@ -1,15 +1,16 @@ // @flow -import { connect } from 'react-redux' -import { bindActionCreators } from 'redux' - -import { loginNep2 } from '../../modules/account' +import { compose } from 'recompose' import LoginNep2 from './LoginNep2' +import withActions from '../../hocs/api/withActions' +import withFailureNotification from '../../hocs/withFailureNotification' +import { nep2LoginActions } from '../../actions/accountActions' -const actionCreators = { - loginNep2 -} - -const mapDispatchToProps = dispatch => bindActionCreators(actionCreators, dispatch) +const mapActionsToProps = (actions) => ({ + loginNep2: (passphrase, encryptedWIF) => actions.request({ passphrase, encryptedWIF }) +}) -export default connect(null, mapDispatchToProps)(LoginNep2) +export default compose( + withActions(nep2LoginActions, mapActionsToProps), + withFailureNotification(nep2LoginActions) +)(LoginNep2) diff --git a/app/containers/LoginPrivateKey/LoginPrivateKey.jsx b/app/containers/LoginPrivateKey/LoginPrivateKey.jsx index f3c138c0b..cec65c614 100644 --- a/app/containers/LoginPrivateKey/LoginPrivateKey.jsx +++ b/app/containers/LoginPrivateKey/LoginPrivateKey.jsx @@ -8,8 +8,7 @@ import Button from '../../components/Button' import loginStyles from '../../styles/login.scss' type Props = { - loginWithPrivateKey: Function, - history: Object + loginWithPrivateKey: Function } type State = { @@ -22,14 +21,14 @@ export default class LoginPrivateKey extends Component { } render () { - const { history, loginWithPrivateKey } = this.props + const { loginWithPrivateKey } = this.props const { wif } = this.state const loginButtonDisabled = wif === '' return (
Login using a private key:
-
{ e.preventDefault(); loginWithPrivateKey(wif, history) }}> + { e.preventDefault(); loginWithPrivateKey(wif) }}>
({ + loginWithPrivateKey: (wif) => actions.request({ wif }) +}) -const mapDispatchToProps = dispatch => bindActionCreators(actionCreators, dispatch) - -export default connect(null, mapDispatchToProps)(LoginPrivateKey) +export default compose( + withActions(wifLoginActions, mapActionsToProps), + withFailureNotification(wifLoginActions) +)(LoginPrivateKey) diff --git a/app/containers/Settings/Settings.jsx b/app/containers/Settings/Settings.jsx index 56e158303..2f457f5ee 100644 --- a/app/containers/Settings/Settings.jsx +++ b/app/containers/Settings/Settings.jsx @@ -38,15 +38,6 @@ export default class Settings extends Component { currency: this.props.currency } - componentDidMount () { - const { setAccounts } = this.props - - // eslint-disable-next-line - storage.get('userWallet', (error, data) => { - setAccounts(data.accounts) - }) - } - saveWalletRecovery = () => { const { showSuccessNotification, showErrorNotification } = this.props diff --git a/app/containers/Settings/index.js b/app/containers/Settings/index.js index fa51939d9..24ed99f21 100644 --- a/app/containers/Settings/index.js +++ b/app/containers/Settings/index.js @@ -4,30 +4,35 @@ import { bindActionCreators } from 'redux' import { compose } from 'recompose' import Settings from './Settings' +import withData from '../../hocs/api/withData' import withActions from '../../hocs/api/withActions' import withExplorerData from '../../hocs/withExplorerData' import withCurrencyData from '../../hocs/withCurrencyData' -import { setAccounts, getAccounts } from '../../modules/account' +import accountsActions, { updateAccountsActions } from '../../actions/accountsActions' import { updateSettingsActions } from '../../actions/settingsActions' import { getNetworks } from '../../core/networks' import { showErrorNotification, showSuccessNotification } from '../../modules/notifications' import { showModal } from '../../modules/modal' const mapStateToProps = (state: Object) => ({ - accounts: getAccounts(state), networks: getNetworks() }) const actionCreators = { - setAccounts, showModal, showErrorNotification, showSuccessNotification } -const mapDispatchToProps = dispatch => bindActionCreators(actionCreators, dispatch) +const mapDispatchToProps = (dispatch) => bindActionCreators(actionCreators, dispatch) -const mapActionsToProps = (actions) => ({ +const mapAccountsDataToProps = (accounts) => ({ accounts }) + +const mapAccountsActionsToProps = (actions) => ({ + setAccounts: (accounts) => updateAccountsActions.request(accounts) +}) + +const mapSettingsActionsToProps = (actions) => ({ setCurrency: (currency) => actions.request({ currency }), setBlockExplorer: (blockExplorer) => actions.request({ blockExplorer }), setUserGeneratedTokens: (tokens) => actions.request({ tokens }) @@ -35,7 +40,9 @@ const mapActionsToProps = (actions) => ({ export default compose( connect(mapStateToProps, mapDispatchToProps), + withData(accountsActions, mapAccountsDataToProps), withExplorerData(), withCurrencyData(), - withActions(updateSettingsActions, mapActionsToProps) + withActions(updateAccountsActions, mapAccountsActionsToProps), + withActions(updateSettingsActions, mapSettingsActionsToProps) )(Settings) diff --git a/app/containers/TransactionHistory/index.js b/app/containers/TransactionHistory/index.js index 490a8bd2b..e0386af08 100644 --- a/app/containers/TransactionHistory/index.js +++ b/app/containers/TransactionHistory/index.js @@ -4,13 +4,12 @@ import { bindActionCreators } from 'redux' import { compose } from 'recompose' import withNetworkData from '../../hocs/withNetworkData' +import withAccountData from '../../hocs/withAccountData' import { syncTransactionHistory, getIsLoadingTransactions } from '../../modules/transactions' -import { getAddress } from '../../modules/account' import { getTransactions } from '../../modules/wallet' import TransactionHistory from './TransactionHistory' const mapStateToProps: MapStateToProps<*, *, *> = (state: Object) => ({ - address: getAddress(state), transactions: getTransactions(state), isLoadingTransactions: getIsLoadingTransactions(state) }) @@ -23,5 +22,6 @@ const mapDispatchToProps = dispatch => bindActionCreators(actionCreators, dispat export default compose( connect(mapStateToProps, mapDispatchToProps), - withNetworkData() + withNetworkData(), + withAccountData() )(TransactionHistory) diff --git a/app/containers/WalletInfo/index.js b/app/containers/WalletInfo/index.js index 4d70536e7..f579507ee 100644 --- a/app/containers/WalletInfo/index.js +++ b/app/containers/WalletInfo/index.js @@ -7,11 +7,11 @@ import pricesActions from '../../actions/pricesActions' import withData from '../../hocs/api/withData' import withActions from '../../hocs/api/withActions' import withNetworkData from '../../hocs/withNetworkData' +import withAccountData from '../../hocs/withAccountData' import withCurrencyData from '../../hocs/withCurrencyData' import { updateSettingsActions } from '../../actions/settingsActions' import { getNetworks } from '../../core/networks' import { showErrorNotification, showSuccessNotification } from '../../modules/notifications' -import { getAddress } from '../../modules/account' import { loadWalletData, getNEO, getGAS, getTokenBalances } from '../../modules/wallet' import { showModal } from '../../modules/modal' import { participateInSale, oldParticipateInSale } from '../../modules/sale' @@ -21,7 +21,6 @@ import WalletInfo from './WalletInfo' const mapStateToProps: MapStateToProps<*, *, *> = (state: Object) => ({ NEO: getNEO(state), GAS: getGAS(state), - address: getAddress(state), tokenBalances: getTokenBalances(state), networks: getNetworks() }) @@ -50,6 +49,7 @@ export default compose( connect(mapStateToProps, mapDispatchToProps), withData(pricesActions, mapPricesDataToProps), withNetworkData(), + withAccountData(), withCurrencyData('currencyCode'), withActions(updateSettingsActions, mapActionsToProps) )(WalletInfo) diff --git a/app/core/account.js b/app/core/account.js new file mode 100644 index 000000000..bac302167 --- /dev/null +++ b/app/core/account.js @@ -0,0 +1,26 @@ +// @flow +import { wallet } from 'neon-js' + +import { getStorage, setStorage } from './storage' + +function getNEP6AddressData (matchesEncryptedWIF: boolean, address: string) { + if (matchesEncryptedWIF) { + return { address } + } else { + return {} + } +} + +export async function upgradeNEP6AddAddresses (encryptedWIF: string, wif: string) { + const data = getStorage('userWallet') + const loggedIntoAccount = new wallet.Account(wif) + + if (data && data.accounts) { + const accounts = data.accounts.map((account, idx) => ({ + ...account, + ...getNEP6AddressData(account.key === encryptedWIF, loggedIntoAccount.address) + })) + + await setStorage('userWallet', { ...data, accounts }) + } +} diff --git a/app/core/constants.js b/app/core/constants.js index 70746b634..eb04b5c1b 100644 --- a/app/core/constants.js +++ b/app/core/constants.js @@ -127,6 +127,3 @@ export const CURRENCIES = { usd: { symbol: '$' }, zar: { symbol: 'R ' } } - -export const FINDING_LEDGER_NOTICE = - 'Looking for USB Devices. Please plugin your device and login.' diff --git a/app/core/deprecated.js b/app/core/deprecated.js index 7123f9480..13005ee47 100644 --- a/app/core/deprecated.js +++ b/app/core/deprecated.js @@ -3,6 +3,7 @@ import { get } from 'lodash' import blockHeightActions from '../actions/blockHeightActions' import pricesActions from '../actions/pricesActions' +import { ID as ACCOUNT_ID } from '../actions/accountActions' import { ID as NETWORK_ID } from '../actions/networkActions' import { ID as SETTINGS_ID } from '../actions/settingsActions' import { getNetworks } from '../core/networks' @@ -32,6 +33,26 @@ export const getNetworkById = (networkId: string) => { return networkItem.network } +export const getAddress = (state: Object) => { + return get(state, `${PREFIX}.${ACCOUNT_ID}.data.address`) +} + +export const getWIF = (state: Object) => { + return get(state, `${PREFIX}.${ACCOUNT_ID}.data.wif`) +} + +export const getSigningFunction = (state: Object) => { + return get(state, `${PREFIX}.${ACCOUNT_ID}.data.signingFunction`) +} + +export const getPublicKey = (state: Object) => { + return get(state, `${PREFIX}.${ACCOUNT_ID}.data.publicKey`) +} + +export const getIsHardwareLogin = (state: Object) => { + return get(state, `${PREFIX}.${ACCOUNT_ID}.data.isHardwareLogin`) +} + export const getCurrency = (state: Object) => { return get(state, `${PREFIX}.${SETTINGS_ID}.data.currency`) } diff --git a/app/hocs/api/progressStrategies/alreadyLoadedStrategy.js b/app/hocs/api/progressStrategies/alreadyLoadedStrategy.js index eff9c8111..8b8a33dcc 100644 --- a/app/hocs/api/progressStrategies/alreadyLoadedStrategy.js +++ b/app/hocs/api/progressStrategies/alreadyLoadedStrategy.js @@ -14,7 +14,7 @@ function alreadyLoaded (actionStates: Array): boolean { export default function alreadyLoadedStrategy (actions: Array): ProgressState { if (anyFailed(actions)) { return FAILED - } else if (alreadyLoaded(actions)) { + } else if (alreadyLoaded(actions) && actions.length > 0) { return LOADED } else { return LOADING diff --git a/app/hocs/api/progressStrategies/defaultStrategy.js b/app/hocs/api/progressStrategies/defaultStrategy.js index 15fdb4b69..432bf9795 100644 --- a/app/hocs/api/progressStrategies/defaultStrategy.js +++ b/app/hocs/api/progressStrategies/defaultStrategy.js @@ -14,7 +14,7 @@ function allLoaded (actionStates: Array): boolean { export default function defaultStrategy (actions: Array): ProgressState { if (anyFailed(actions)) { return FAILED - } else if (allLoaded(actions)) { + } else if (allLoaded(actions) && actions.length > 0) { return LOADED } else { return LOADING diff --git a/app/hocs/api/withData.js b/app/hocs/api/withData.js index 39d5c542e..9d30debe9 100644 --- a/app/hocs/api/withData.js +++ b/app/hocs/api/withData.js @@ -15,10 +15,12 @@ const mapBatchDataToProps: Function = (state: Object, id: string, mapping: Actio return mapValues(mapping, (key) => mapDataToProps(state, key, prefix)) } -const mapDataToProps: Function = (state: Object, id: string, prefix: string): Object => { +const mapDataToProps: Function = (state: Object, id: string, prefix: string): any => { const actionState = get(state, `${prefix}.${id}`) - if (actionState.batch) { + if (!actionState) { + return null + } else if (actionState.batch) { return mapBatchDataToProps(state, id, actionState.mapping, prefix) } else { return mapRequestDataToProps(actionState.data) diff --git a/app/hocs/api/withError.js b/app/hocs/api/withError.js new file mode 100644 index 000000000..bfd05638d --- /dev/null +++ b/app/hocs/api/withError.js @@ -0,0 +1,47 @@ +// @flow +import { get, mapValues } from 'lodash' +import { connect, type MapStateToProps } from 'react-redux' +import { compose, setDisplayName, wrapDisplayName } from 'recompose' + +import { type Error, type Actions, type ActionStateMap } from '../../values/api' + +type Options = { + prefix: string +} + +const mapRequestErrorToProps: Function = (error: Error): any => error + +const mapBatchErrorToProps: Function = (state: Object, id: string, mapping: ActionStateMap, prefix: string): Object => { + return mapValues(mapping, (key) => mapErrorToProps(state, key, prefix)) +} + +const mapErrorToProps: Function = (state: Object, id: string, prefix: string): any => { + const actionState = get(state, `${prefix}.${id}`) + + if (!actionState) { + return null + } else if (actionState.batch) { + return mapBatchErrorToProps(state, id, actionState.mapping, prefix) + } else { + return mapRequestErrorToProps(actionState.error) + } +} + +const defaultMapper = mapRequestErrorToProps + +export default function withError ( + actions: Actions, + mapper: Function = defaultMapper, + { prefix = 'api' }: Options = {} +): Class> { + const mapStateToProps: MapStateToProps<*, *, *> = (state: Object, ownProps: Object): Object => { + return mapper(mapErrorToProps(state, actions.id, prefix), ownProps) + } + + return (Component: Class>) => { + return compose( + connect(mapStateToProps), + setDisplayName(wrapDisplayName(Component, 'withError')) + )(Component) + } +} diff --git a/app/hocs/api/withProgressProp.js b/app/hocs/api/withProgressProp.js index 71a64f876..312ec5066 100644 --- a/app/hocs/api/withProgressProp.js +++ b/app/hocs/api/withProgressProp.js @@ -21,15 +21,21 @@ export default function withProgressProp ( ) { const mapProgressToProps = (actionStates: Array) => ({ [propName]: strategy(actionStates) }) - const mapStateToProps: MapStateToProps<*, *, *> = (state: Object): Object => { + // TODO: this doesn't account for batch within a batch, need to make this recursive + const getActionStates = (state: Object): Array => { const actionState = get(state, `${prefix}.${actions.id}`) - // TODO: this doesn't account for batch within a batch, need to make this recursive - const actionStates: Array = actionState.batch - ? map(actionState.mapping, (key) => get(state, `${prefix}.${key}`)) - : castArray(actionState) + if (!actionState) { + return [] + } else if (actionState.batch) { + return map(actionState.mapping, (key) => get(state, `${prefix}.${key}`)) + } else { + return castArray(actionState) + } + } - return mapProgressToProps(actionStates) + const mapStateToProps: MapStateToProps<*, *, *> = (state: Object): Object => { + return mapProgressToProps(getActionStates(state)) } return (Component: Class>) => { diff --git a/app/hocs/auth/withLoginRedirect.js b/app/hocs/auth/withLoginRedirect.js new file mode 100644 index 000000000..99d994f5c --- /dev/null +++ b/app/hocs/auth/withLoginRedirect.js @@ -0,0 +1,9 @@ +// @flow +import { wallet } from 'neon-js' + +import withRedirect from './withRedirect' +import { ROUTES } from '../../core/constants' + +export default withRedirect(ROUTES.DASHBOARD, (oldAddress, newAddress) => { + return !oldAddress && wallet.isAddress(newAddress) +}) diff --git a/app/hocs/auth/withLogoutRedirect.js b/app/hocs/auth/withLogoutRedirect.js new file mode 100644 index 000000000..0cda88c6d --- /dev/null +++ b/app/hocs/auth/withLogoutRedirect.js @@ -0,0 +1,9 @@ +// @flow +import { wallet } from 'neon-js' + +import withRedirect from './withRedirect' +import { ROUTES } from '../../core/constants' + +export default withRedirect(ROUTES.HOME, (oldAddress, newAddress) => { + return wallet.isAddress(oldAddress) && !newAddress +}) diff --git a/app/hocs/auth/withRedirect.js b/app/hocs/auth/withRedirect.js new file mode 100644 index 000000000..2f12ff7d6 --- /dev/null +++ b/app/hocs/auth/withRedirect.js @@ -0,0 +1,49 @@ +// @flow +import React from 'react' +import { omit } from 'lodash' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' + +import withData from '../api/withData' +import accountActions from '../../actions/accountActions' + +type Props = { + [key: string]: string, + history: { + push: Function + } +} + +type Options = { + propName?: string +} + +export default function withRedirect ( + route: string, + strategy: Function, + { propName = '__address__' }: Options = {} +) { + const mapAccountDataToProps = (account) => ({ + [propName]: account && account.address + }) + + return (Component: Class>) => { + class WrappedComponent extends React.Component { + componentWillReceiveProps (nextProps) { + if (strategy(this.props[propName], nextProps[propName])) { + this.props.history.push(route) + } + } + + render () { + const passDownProps = omit(this.props, propName) + return + } + } + + return compose( + withRouter, + withData(accountActions, mapAccountDataToProps) + )(WrappedComponent) + } +} diff --git a/app/hocs/withAccountData.js b/app/hocs/withAccountData.js new file mode 100644 index 000000000..24eacf9eb --- /dev/null +++ b/app/hocs/withAccountData.js @@ -0,0 +1,25 @@ +// @flow +import { isEmpty, zipObject, mapValues, invert } from 'lodash' + +import withData from './api/withData' +import accountActions from '../actions/accountActions' + +type Mapping = { + address?: string, + publicKey?: string, + wif?: string, + signingFunction?: string +} + +const keys: Array = ['address', 'publicKey', 'wif', 'signingFunction'] +const defaultMapping: Mapping = zipObject(keys, keys) + +export default function withAccountData (mapping: Mapping = defaultMapping) { + return withData(accountActions, (account) => { + if (!isEmpty(account)) { + return mapValues(invert(mapping), (field) => account[field]) + } else { + return {} + } + }) +} diff --git a/app/hocs/withFailureNotification.js b/app/hocs/withFailureNotification.js new file mode 100644 index 000000000..fb227568b --- /dev/null +++ b/app/hocs/withFailureNotification.js @@ -0,0 +1,57 @@ +// @flow +import React from 'react' +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { omit } from 'lodash' + +import withError from './api/withError' +import withProgressProp from './api/withProgressProp' +import { type Actions } from '../values/api' +import { FAILED, type ProgressState } from '../values/state' +import { showErrorNotification } from '../modules/notifications' + +type Props = { + __error__: string, + __progress__: ProgressState, + __showErrorNotification__: Function +} + +const ERROR_PROP = '__error__' +const PROGRESS_PROP = '__progress__' +const NOTIFICATION_PROP = '__showErrorNotification__' + +export default function withFailureNotifications (actions: Actions) { + const mapErrorToProps = (error) => ({ [ERROR_PROP]: error }) + + const mapDisptchToProps = (dispatch, ownProps) => ({ + [NOTIFICATION_PROP]: (...args) => dispatch(showErrorNotification(...args)) + }) + + return (Component: Class>): Class> => { + const hasError = (props: Props) => !!props[ERROR_PROP] + + const progressChangedToError = (prevProps: Props, nextProps: Props) => { + return prevProps[PROGRESS_PROP] !== FAILED && nextProps[PROGRESS_PROP] === FAILED + } + + class ErrorNotifier extends React.Component { + componentWillReceiveProps (nextProps) { + if (hasError(nextProps) && (!hasError(this.props) || progressChangedToError(this.props, nextProps))) { + const showErrorNotification = nextProps[NOTIFICATION_PROP] + showErrorNotification({ message: nextProps[ERROR_PROP] }) + } + } + + render () { + const passDownProps = omit(this.props, ERROR_PROP, PROGRESS_PROP, NOTIFICATION_PROP) + return + } + } + + return compose( + connect(null, mapDisptchToProps), + withError(actions, mapErrorToProps), + withProgressProp(actions, { propName: PROGRESS_PROP }) + )(ErrorNotifier) + } +} diff --git a/app/ledger/connect.js b/app/ledger/connect.js new file mode 100644 index 000000000..79dcfa92d --- /dev/null +++ b/app/ledger/connect.js @@ -0,0 +1,24 @@ +// @flow +import commNode from './ledger-comm-node' + +async function list () { + try { + return await commNode.list_async() + } catch (err) { + throw new Error(`USB Error: ${err.message}.`) + } +} + +export default async function connect () { + const devices = await list() + + if (devices.length === 0) { + throw new Error('USB Error: No device found.') + } + + try { + return await commNode.create_async() + } catch (error) { + throw new Error(`USB Error: Login to NEO App and try again.`) + } +} diff --git a/app/ledger/getDeviceInfo.js b/app/ledger/getDeviceInfo.js new file mode 100644 index 000000000..73b267b1a --- /dev/null +++ b/app/ledger/getDeviceInfo.js @@ -0,0 +1,12 @@ +// @flow +import connect from './connect' + +export default async function getDeviceInfo () { + const comm = await connect() + + try { + return comm.device.getDeviceInfo() + } finally { + comm.device.close() + } +} diff --git a/app/ledger/getPublicKey.js b/app/ledger/getPublicKey.js new file mode 100644 index 000000000..a489bb77f --- /dev/null +++ b/app/ledger/getPublicKey.js @@ -0,0 +1,25 @@ +// @flow +import connect from './connect' +import { BIP44_PATH } from '../core/constants' + +const MESSAGE = Buffer.from(`8004000000${BIP44_PATH}`, 'hex').toString('hex') +const VALID_STATUS = [0x9000] + +export default async function getPublicKey () { + const comm = await connect() + + try { + const response = await comm.exchange(MESSAGE, VALID_STATUS) + return response.substring(0, 130) + } catch (error) { + console.error(error) + + if (error === 'Invalid status 28160') { + throw new Error('NEO App does not appear to be open, request for private key returned error 28160.') + } else { + throw new Error('Hardware Device Error. Login to NEO App and try again') + } + } finally { + comm.device.close() + } +} diff --git a/app/modules/account.js b/app/modules/account.js deleted file mode 100644 index 62f95e5bf..000000000 --- a/app/modules/account.js +++ /dev/null @@ -1,289 +0,0 @@ -// @flow -import { wallet } from 'neon-js' -import storage from 'electron-json-storage' - -import { showErrorNotification, showInfoNotification, hideNotification } from './notifications' - -import commNode from '../ledger/ledger-comm-node' -import { ledgerNanoSCreateSignatureAsync } from '../ledger/ledgerNanoS' - -import { validatePassphraseLength } from '../core/wallet' -import { BIP44_PATH, ROUTES, FINDING_LEDGER_NOTICE } from '../core/constants' -import asyncWrap from '../core/asyncHelper' - -// Constants -export const LOGIN = 'LOGIN' -export const LOGOUT = 'LOGOUT' -export const SET_ACCOUNTS = 'SET_ACCOUNTS' -export const HARDWARE_DEVICE_INFO = 'HARDWARE_DEVICE_INFO' -export const HARDWARE_PUBLIC_KEY_INFO = 'HARDWARE_PUBLIC_KEY_INFO' -export const HARDWARE_PUBLIC_KEY = 'HARDWARE_PUBLIC_KEY' -export const HARDWARE_LOGIN = 'HARDWARE_LOGIN' - -// Actions -export function login (wif: string) { - return { - type: LOGIN, - payload: { wif } - } -} - -export function ledgerNanoSGetLogin () { - return { - type: LOGIN, - payload: { signingFunction: ledgerNanoSCreateSignatureAsync } - } -} - -export function logout () { - return { - type: LOGOUT - } -} - -export function setAccounts (accounts: any) { - return { - type: SET_ACCOUNTS, - payload: { accounts } - } -} - -export function upgradeNEP6AddAddresses (encryptedWIF: string, wif: string) { - // eslint-disable-next-line - storage.get('userWallet', (error, data) => { - const loggedIntoAccount = new wallet.Account(wif) - - if (data && data.accounts) { - data.accounts.map((account, idx) => { - if (account.key === encryptedWIF) { - data.accounts[idx].address = loggedIntoAccount.address - } - }) - - storage.set('userWallet', data) - } - }) -} - -export const loginNep2 = (passphrase: string, encryptedWIF: string, history: Object) => (dispatch: DispatchType) => { - const dispatchError = (message: string) => dispatch(showErrorNotification({ message })) - - if (!validatePassphraseLength(passphrase)) { - return dispatchError('Passphrase too short') - } - - if (!wallet.isNEP2(encryptedWIF)) { - return dispatchError('That is not a valid encrypted key') - } - - const infoNotificationId: any = dispatch(showInfoNotification({ message: 'Decrypting encoded key...' })) - - setTimeout(() => { - try { - const wif = wallet.decrypt(encryptedWIF, passphrase) - - upgradeNEP6AddAddresses(encryptedWIF, wif) - - dispatch(hideNotification(infoNotificationId)) - dispatch(login(wif)) - return history.push(ROUTES.DASHBOARD) - } catch (e) { - return dispatchError('Wrong passphrase or invalid encrypted key') - } - }, 500) -} - -export function hardwareDeviceInfo (hardwareDeviceInfo: string) { - return { - type: HARDWARE_DEVICE_INFO, - payload: { hardwareDeviceInfo } - } -} - -export function hardwarePublicKeyInfo (hardwarePublicKeyInfo: ?string) { - return { - type: HARDWARE_PUBLIC_KEY_INFO, - payload: { hardwarePublicKeyInfo } - } -} - -export function hardwarePublicKey (publicKey: ?string) { - return { - type: HARDWARE_PUBLIC_KEY, - payload: { publicKey } - } -} - -export function isHardwareLogin (isHardwareLogin: boolean) { - return { - type: HARDWARE_LOGIN, - payload: { isHardwareLogin } - } -} - -export const loginWithPrivateKey = (wif: string, history: Object, route?: RouteType) => (dispatch: DispatchType) => { - if (wallet.isWIF(wif)) { - dispatch(login(wif)) - return history.push(route || ROUTES.DASHBOARD) - } else { - return dispatch(showErrorNotification({ message: 'That is not a valid private key' })) - } -} - -// Reducer that manages account state (account now = private key) -export const ledgerNanoSGetInfoAsync = () => async (dispatch: DispatchType) => { - const dispatchError = (message: string, deviceInfoMsg: boolean = true) => { - dispatch(isHardwareLogin(false)) - dispatch(hardwarePublicKey(null)) - if (deviceInfoMsg) { - dispatch(hardwarePublicKeyInfo(null)) - return dispatch(hardwareDeviceInfo(message)) - } else { - return dispatch(hardwarePublicKeyInfo(message)) - } - } - dispatch(hardwareDeviceInfo(FINDING_LEDGER_NOTICE)) - let [err, result] = await asyncWrap(commNode.list_async()) - if (err) { - return dispatchError(`Finding USB Error: ${err}. ${FINDING_LEDGER_NOTICE}`) - } - if (result.length === 0) { - return dispatchError(`USB Failure: No device found. ${FINDING_LEDGER_NOTICE}`) - } else { - let [err, comm] = await asyncWrap(commNode.create_async()) - if (err) { - return dispatchError(`Finding USB Error: ${err}. ${FINDING_LEDGER_NOTICE}`) - } - - const deviceInfo = comm.device.getDeviceInfo() - comm.device.close() - dispatch(hardwareDeviceInfo(`Found USB ${deviceInfo.manufacturer} ${deviceInfo.product}`)) - } - [err, result] = await asyncWrap(commNode.list_async()) - if (result.length === 0) { - return dispatchError('Hardware Device Error. Login to NEO App and try again', false) - } else { - let [err, comm] = await asyncWrap(commNode.create_async()) - if (err) { - console.log(`Public Key Comm Init Error: ${err}`) - return dispatchError('Hardware Device Error. Login to NEO App and try again', false) - } - - let message = Buffer.from(`8004000000${BIP44_PATH}`, 'hex') - const validStatus = [0x9000] - let [error, response] = await asyncWrap(comm.exchange(message.toString('hex'), validStatus)) - if (error) { - comm.device.close() // NOTE: do we need this close here - what about the other errors that do not have it at the moment - if (error === 'Invalid status 28160') { - return dispatchError('NEO App does not appear to be open, request for private key returned error 28160.', false) - } else { - console.log(`Public Key Comm Messaging Error: ${error}`) - return dispatchError('Hardware Device Error. Login to NEO App and try again', false) - } - } - comm.device.close() - dispatch(isHardwareLogin(true)) - dispatch(hardwarePublicKey(response.substring(0, 130))) - return dispatch(hardwarePublicKeyInfo('Success. NEO App Found on Hardware Device. Click Button Above to Login')) - } -} - -// State Getters -export const getWIF = (state: Object) => state.account.wif -export const getAddress = (state: Object) => state.account.address -export const getLoggedIn = (state: Object) => state.account.loggedIn -export const getRedirectUrl = (state: Object) => state.account.redirectUrl -export const getAccounts = (state: Object) => state.account.accounts -export const getSigningFunction = (state: Object) => state.account.signingFunction -export const getPublicKey = (state: Object) => state.account.publicKey -export const getHardwareDeviceInfo = (state: Object) => state.account.hardwareDeviceInfo -export const getHardwarePublicKeyInfo = (state: Object) => state.account.hardwarePublicKeyInfo -export const getIsHardwareLogin = (state: Object) => state.account.isHardwareLogin - -const initialState = { - wif: null, - address: null, - loggedIn: false, - redirectUrl: null, - accounts: [], - signingFunction: null, - publicKey: null, - isHardwareLogin: false, - hardwareDeviceInfo: null, - hardwarePublicKeyInfo: null -} - -export default (state: Object = initialState, action: ReduxAction) => { - switch (action.type) { - case LOGIN: - const { signingFunction, wif } = action.payload - let loadAccount: Object | number - try { - if (signingFunction) { - const publicKeyEncoded = wallet.getPublicKeyEncoded(state.publicKey) - loadAccount = new wallet.Account(publicKeyEncoded) - } else { - loadAccount = new wallet.Account(wif) - } - } catch (e) { - console.log(e.stack) - loadAccount = -1 - } - if (typeof loadAccount !== 'object') { - return { - ...state, - wif, - loggedIn: false - } - } - return { - ...state, - wif, - address: loadAccount.address, - loggedIn: true, - signingFunction - } - case LOGOUT: - return { - ...state, - wif: null, - address: null, - loggedIn: false, - signingFunction: null, - publicKey: null, - isHardwareLogin: false - } - case SET_ACCOUNTS: - const { accounts } = action.payload - return { - ...state, - accounts - } - case HARDWARE_DEVICE_INFO: - const { hardwareDeviceInfo } = action.payload - return { - ...state, - hardwareDeviceInfo - } - case HARDWARE_LOGIN: - const { isHardwareLogin } = action.payload - return { - ...state, - isHardwareLogin - } - case HARDWARE_PUBLIC_KEY_INFO: - const { hardwarePublicKeyInfo } = action.payload - return { - ...state, - hardwarePublicKeyInfo - } - case HARDWARE_PUBLIC_KEY: - const { publicKey } = action.payload - return { - ...state, - publicKey - } - default: - return state - } -} diff --git a/app/modules/claim.js b/app/modules/claim.js index 8f110a8c7..5772ec00c 100644 --- a/app/modules/claim.js +++ b/app/modules/claim.js @@ -1,22 +1,20 @@ // @flow import { api } from 'neon-js' +import { getNEO } from './wallet' import { showErrorNotification, showSuccessNotification, showInfoNotification } from './notifications' import { + getNetwork, getWIF, getAddress, getSigningFunction, getPublicKey, - LOGOUT, getIsHardwareLogin -} from './account' -import { getNEO } from './wallet' - -import { getNetwork } from '../core/deprecated' +} from '../core/deprecated' import { ASSETS } from '../core/constants' import asyncWrap from '../core/asyncHelper' import { FIVE_MINUTES_MS } from '../core/time' @@ -223,8 +221,6 @@ export default (state: Object = initialState, action: ReduxAction) => { ...state, disableClaimButton } - case LOGOUT: - return initialState default: return state } diff --git a/app/modules/dashboard.js b/app/modules/dashboard.js deleted file mode 100644 index fa7392b2c..000000000 --- a/app/modules/dashboard.js +++ /dev/null @@ -1,13 +0,0 @@ -// @flow -import { LOGOUT } from './account' - -const initialState = {} - -export default (state: Object = initialState, action: ReduxAction) => { - switch (action.type) { - case LOGOUT: - return initialState - default: - return state - } -} diff --git a/app/modules/index.js b/app/modules/index.js index 974e65e90..a62257730 100644 --- a/app/modules/index.js +++ b/app/modules/index.js @@ -1,27 +1,21 @@ // @flow import { combineReducers } from 'redux' import api from './api' -import account from './account' import generateWallet from './generateWallet' import transactions from './transactions' import wallet from './wallet' import claim from './claim' -import dashboard from './dashboard' import notifications from './notifications' import modal from './modal' import addressBook from './addressBook' -import sale from './sale' export default combineReducers({ api, - account, generateWallet, wallet, transactions, - dashboard, claim, notifications, modal, - addressBook, - sale + addressBook }) diff --git a/app/modules/sale.js b/app/modules/sale.js index 98933a308..550d32bc7 100644 --- a/app/modules/sale.js +++ b/app/modules/sale.js @@ -4,15 +4,14 @@ import { flatten } from 'lodash' import { showErrorNotification, showInfoNotification, hideNotification } from './notifications' import { + getNetwork, getWIF, - LOGOUT, getAddress, getIsHardwareLogin, getSigningFunction, getPublicKey -} from './account' +} from '../core/deprecated' import { getNEO, getGAS } from './wallet' -import { getNetwork } from '../core/deprecated' import { toNumber } from '../core/math' import asyncWrap from '../core/asyncHelper' import { ASSETS } from '../core/constants' @@ -187,14 +186,3 @@ export const oldParticipateInSale = ( dispatch(hideNotification(notificationId)) return true } - -const initialState = {} - -export default (state: Object = initialState, action: ReduxAction) => { - switch (action.type) { - case LOGOUT: - return initialState - default: - return state - } -} diff --git a/app/modules/transactions.js b/app/modules/transactions.js index 4b81a131e..c187ff08e 100644 --- a/app/modules/transactions.js +++ b/app/modules/transactions.js @@ -10,14 +10,13 @@ import { showSuccessNotification } from './notifications' import { + getNetwork, getWIF, getPublicKey, getSigningFunction, getAddress, - LOGOUT, getIsHardwareLogin -} from './account' -import { getNetwork } from '../core/deprecated' +} from '../core/deprecated' import { isToken, validateTransactionsBeforeSending } from '../core/wallet' import { ASSETS } from '../core/constants' import asyncWrap from '../core/asyncHelper' @@ -236,8 +235,6 @@ export default (state: Object = initialState, action: ReduxAction) => { ...state, isLoadingTransactions } - case LOGOUT: - return initialState default: return state } diff --git a/app/modules/wallet.js b/app/modules/wallet.js index f540caaca..319f7e437 100644 --- a/app/modules/wallet.js +++ b/app/modules/wallet.js @@ -4,11 +4,10 @@ import { isNil } from 'lodash' import { syncTransactionHistory } from './transactions' import { syncAvailableClaim } from './claim' -import { LOGOUT, getAddress } from './account' import { showErrorNotification } from './notifications' import { ASSETS } from '../core/constants' -import { getNetwork, getTokensForNetwork, getMarketPrices, syncBlockHeight } from '../core/deprecated' +import { getAddress, getNetwork, getTokensForNetwork, getMarketPrices, syncBlockHeight } from '../core/deprecated' import asyncWrap from '../core/asyncHelper' import { getTokenBalancesMap } from '../core/wallet' import { COIN_DECIMAL_LENGTH } from '../core/formatters' @@ -202,8 +201,6 @@ export default (state: State = initialState, action: ReduxAction) => { ...state, loaded } - case LOGOUT: - return initialState default: return state } diff --git a/app/values/api.js b/app/values/api.js index 69ca59130..e8c089db1 100644 --- a/app/values/api.js +++ b/app/values/api.js @@ -17,8 +17,8 @@ export const BATCH_SUCCESS: BatchType = 'BATCH/SUCCESS' export const BATCH_FAILURE: BatchType = 'BATCH/FAILURE' export const BATCH_RESET: BatchType = 'BATCH/RESET' -export type Data = Object | null -export type Error = string | null +export type Data = ?Object +export type Error = ?string export type Payload = any export type ActionTypeMap = {