Browse Source

Improved AddAccount Modal

master
Loëck Vézien 7 years ago
parent
commit
ccdc8ef523
No known key found for this signature in database GPG Key ID: CBCDCE384E853AC4
  1. 13
      package.json
  2. 21
      src/components/DashboardPage/AccountCard.js
  3. 271
      src/components/DeviceConnect/index.js
  4. 45
      src/components/DeviceConnect/stories.js
  5. 152
      src/components/DeviceMonitNew/index.js
  6. 41
      src/components/modals/AddAccount/01-step-currency.js
  7. 36
      src/components/modals/AddAccount/02-step-connect-device.js
  8. 100
      src/components/modals/AddAccount/03-step-import.js
  9. 69
      src/components/modals/AddAccount/CreateAccount.js
  10. 131
      src/components/modals/AddAccount/ImportAccounts.js
  11. 39
      src/components/modals/AddAccount/RestoreAccounts.js
  12. 506
      src/components/modals/AddAccount/index.js
  13. 2
      src/components/modals/Send/Footer.js
  14. 16
      src/helpers/btc.js
  15. 12
      src/icons/ExclamationCircle.js
  16. 9
      src/icons/Loader.js
  17. 14
      src/icons/Usb.js
  18. 12
      src/icons/device/Blue.js
  19. 12
      src/icons/device/NanoS.js
  20. 2
      src/icons/device/index.js
  21. 29
      src/internals/usb/devices.js
  22. 13
      src/internals/usb/wallet/accounts.js
  23. 23
      src/internals/usb/wallet/index.js
  24. 2
      src/reducers/settings.js
  25. 6
      src/types/common.js
  26. 15
      static/i18n/en/addAccount.yml
  27. 1
      static/i18n/en/common.yml
  28. 8
      static/i18n/en/deviceConnect.yml
  29. 2
      webpack/renderer.config.js
  30. 4
      webpack/rules.js
  31. 78
      yarn.lock

13
package.json

@ -40,6 +40,7 @@
}
},
"resolutions": {
"uglify-es": "3.3.7",
"webpack-sources": "1.0.1"
},
"dependencies": {
@ -47,7 +48,7 @@
"@ledgerhq/hw-app-btc": "^4.7.3",
"@ledgerhq/hw-app-eth": "^4.7.3",
"@ledgerhq/hw-transport": "^4.7.3",
"@ledgerhq/hw-transport-node-hid": "^4.7.3",
"@ledgerhq/hw-transport-node-hid": "^4.7.6",
"@ledgerhq/wallet-common": "^0.10.1",
"axios": "^0.18.0",
"bcryptjs": "^2.4.3",
@ -72,8 +73,8 @@
"query-string": "^6.0.0",
"raven": "^2.4.2",
"raven-js": "^3.24.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react": "^16.3.0",
"react-dom": "^16.3.0",
"react-i18next": "^7.5.1",
"react-mortal": "^3.2.0",
"react-motion": "^0.5.2",
@ -130,7 +131,7 @@
"eslint-plugin-import": "^2.9.0",
"eslint-plugin-jsx-a11y": "^6.0.3",
"eslint-plugin-react": "^7.7.0",
"flow-bin": "^0.68.0",
"flow-bin": "^0.69.0",
"flow-typed": "^2.4.0",
"hard-source-webpack-plugin": "^0.6.0",
"husky": "^0.14.3",
@ -140,8 +141,8 @@
"node-loader": "^0.6.0",
"prettier": "^1.11.1",
"react-hot-loader": "^4.0.0",
"react-test-renderer": "^16.2.0",
"webpack": "^4.3.0",
"react-test-renderer": "^16.3.0",
"webpack": "^4.4.1",
"webpack-bundle-analyzer": "^2.11.1",
"webpack-cli": "^2.0.13",
"yaml-loader": "^0.5.0"

21
src/components/DashboardPage/AccountCard.js

@ -1,7 +1,9 @@
// @flow
import React from 'react'
import styled from 'styled-components'
import { getIconByCoinType } from '@ledgerhq/currencies/react'
import type { Account } from '@ledgerhq/wallet-common/lib/types'
import Chart from 'components/base/Chart'
@ -10,21 +12,28 @@ import Box, { Card } from 'components/base/Box'
import CalculateBalance from 'components/CalculateBalance'
import FormattedVal from 'components/base/FormattedVal'
const Wrapper = styled(Card).attrs({
p: 4,
flex: 1,
})`
cursor: ${p => (p.onClick ? 'pointer' : 'default')};
`
const AccountCard = ({
counterValue,
account,
onClick,
daysCount,
...props
}: {
counterValue: string,
account: Account,
onClick: Function,
onClick?: Function,
daysCount: number,
}) => {
const Icon = getIconByCoinType(account.currency.coinType)
return (
<Card p={4} flex={1} style={{ cursor: 'pointer' }} onClick={onClick}>
<Wrapper onClick={onClick} {...props}>
<Box flow={4}>
<Box horizontal ff="Open Sans|SemiBold" flow={3} alignItems="center">
<Box
@ -95,8 +104,12 @@ const AccountCard = ({
</Box>
)}
/>
</Card>
</Wrapper>
)
}
AccountCard.defaultProps = {
onClick: undefined,
}
export default AccountCard

271
src/components/DeviceConnect/index.js

@ -0,0 +1,271 @@
// @flow
import React, { PureComponent } from 'react'
import styled from 'styled-components'
import { Trans, translate } from 'react-i18next'
import { getCurrencyByCoinType } from '@ledgerhq/currencies'
import { getIconByCoinType } from '@ledgerhq/currencies/react'
import type { T, Device, Devices } from 'types/common'
import noop from 'lodash/noop'
import Box from 'components/base/Box'
import IconCheck from 'icons/Check'
import IconExclamationCircle from 'icons/ExclamationCircle'
import IconInfoCircle from 'icons/InfoCircle'
import IconLoader from 'icons/Loader'
import IconUsb from 'icons/Usb'
import * as IconDevice from 'icons/device'
const Step = styled(Box).attrs({
borderRadius: 1,
justifyContent: 'center',
fontSize: 4,
})`
border: 1px solid
${p =>
p.validated
? p.theme.colors.wallet
: p.hasErrors ? p.theme.colors.alertRed : p.theme.colors.fog};
`
const StepIcon = styled(Box).attrs({
alignItems: 'center',
justifyContent: 'center',
})`
width: 64px;
`
const StepContent = styled(Box).attrs({
color: 'dark',
horizontal: true,
alignItems: 'center',
})`
height: 60px;
line-height: 1.2;
strong {
font-weight: 600;
}
`
const ListDevices = styled(Box).attrs({
p: 3,
pt: 1,
flow: 2,
})``
const DeviceItem = styled(Box).attrs({
bg: 'lightGrey',
borderRadius: 1,
alignItems: 'center',
color: 'dark',
ff: 'Open Sans|SemiBold',
fontSize: 4,
horizontal: true,
pr: 3,
pl: 0,
})`
cursor: pointer;
height: 54px;
`
const DeviceIcon = styled(Box).attrs({
alignItems: 'center',
justifyContent: 'center',
color: 'graphite',
})`
width: 55px;
`
const DeviceSelected = styled(Box).attrs({
alignItems: 'center',
bg: p => (p.selected ? 'wallet' : 'white'),
color: 'white',
justifyContent: 'center',
})`
border-radius: 50%;
border: 1px solid ${p => (p.selected ? p.theme.colors.wallet : p.theme.colors.fog)};
height: 18px;
width: 18px;
`
const WrapperIconCurrency = styled(Box).attrs({
alignItems: 'center',
justifyContent: 'center',
})`
border: 1px solid ${p => p.theme.colors[p.color]};
border-radius: 8px;
height: 24px;
width: 24px;
`
const Info = styled(Box).attrs({
alignItems: 'center',
color: p => (p.hasErrors ? 'alertRed' : 'grey'),
flow: 2,
fontSize: 3,
horizontal: true,
ml: 1,
pt: 1,
})`
strong {
font-weight: 600;
}
`
const StepCheck = ({ checked, hasErrors }: { checked: boolean, hasErrors?: boolean }) => (
<Box pr={5}>
{checked ? (
<Box color="wallet">
<IconCheck size={16} />
</Box>
) : hasErrors ? (
<Box color="alertRed">
<IconExclamationCircle size={16} />
</Box>
) : (
<IconLoader size={16} />
)}
</Box>
)
StepCheck.defaultProps = {
hasErrors: false,
}
type Props = {
accountName: null | string,
appOpened: null | 'success' | 'fail',
coinType: number,
devices: Devices,
deviceSelected: Device | null,
onChangeDevice: Function,
t: T,
}
const emitChangeDevice = props => {
const { onChangeDevice, deviceSelected, devices } = props
if (deviceSelected === null && devices.length > 0) {
onChangeDevice(devices[0])
}
}
class DeviceConnect extends PureComponent<Props> {
static defaultProps = {
accountName: null,
appOpened: null,
devices: [],
deviceSelected: null,
onChangeDevice: noop,
}
componentDidMount() {
emitChangeDevice(this.props)
}
componentWillReceiveProps(nextProps) {
emitChangeDevice(nextProps)
}
getAppState() {
const { appOpened } = this.props
return {
success: appOpened === 'success',
fail: appOpened === 'fail',
}
}
render() {
const { deviceSelected, accountName, coinType, t, onChangeDevice, devices } = this.props
const appState = this.getAppState()
const hasDevice = devices.length > 0
const hasMultipleDevices = devices.length > 1
const { name: appName } = getCurrencyByCoinType(coinType)
const IconCurrency = getIconByCoinType(coinType)
return (
<Box flow={4}>
<Step validated={hasDevice}>
<StepContent>
<StepIcon>
<IconUsb size={36} />
</StepIcon>
<Box grow shrink>
<Trans i18nKey="deviceConnect:step1.connect" parent="div">
Connect your <strong>Ledger device</strong> to your computer and enter your{' '}
<strong>PIN code</strong> on your device
</Trans>
</Box>
<StepCheck checked={hasDevice} />
</StepContent>
{hasMultipleDevices && (
<ListDevices>
<Box color="graphite" fontSize={3}>
{t('deviceConnect:step1.choose', { devicesCount: devices.length })}
</Box>
<Box flow={2}>
{devices.map((d, i) => {
const Icon = IconDevice[d.product.replace(/\s/g, '')]
return (
<DeviceItem
key={i} // eslint-disable-line react/no-array-index-key
onClick={() => onChangeDevice(d)}
>
<DeviceIcon>
<Icon size={28} />
</DeviceIcon>
<Box grow noShrink>
{`${d.manufacturer} ${d.product}`}
</Box>
<Box>
<DeviceSelected selected={d === deviceSelected}>
<IconCheck size={10} />
</DeviceSelected>
</Box>
</DeviceItem>
)
})}
</Box>
</ListDevices>
)}
</Step>
<Step validated={appState.success} hasErrors={appState.fail}>
<StepContent>
<StepIcon>
<WrapperIconCurrency>
<IconCurrency size={12} />
</WrapperIconCurrency>
</StepIcon>
<Box grow shrink>
<Trans i18nKey="deviceConnect:step2.open" parent="div">
{/* $FlowFixMe */}
Open <strong>{{ appName }} App</strong> on your device
</Trans>
</Box>
<StepCheck checked={appState.success} hasErrors={appState.fail} />
</StepContent>
</Step>
{accountName !== null && (
<Info hasErrors={appState.fail}>
<Box>
<IconInfoCircle size={12} />
</Box>
<Box>
<Trans i18nKey="deviceConnect:info" parent="div">
{/* $FlowFixMe */}
You must use the device associated to the account <strong>{{ accountName }}</strong>
</Trans>
</Box>
</Info>
)}
</Box>
)
}
}
export default translate()(DeviceConnect)

45
src/components/DeviceConnect/stories.js

@ -0,0 +1,45 @@
// @flow
import React from 'react'
import { storiesOf } from '@storybook/react'
import { select, text } from '@storybook/addon-knobs'
import { action } from '@storybook/addon-actions'
import DeviceConnect from 'components/DeviceConnect'
const stories = storiesOf('Components', module)
const devices = [
{
manufacturer: 'Ledger',
product: 'Nano S',
vendorId: '1',
productId: '11',
path: '111',
},
{
manufacturer: 'Ledger',
product: 'Blue',
vendorId: '2',
productId: '22',
path: '222',
},
{
manufacturer: 'Ledger',
product: 'Nano S',
vendorId: '3',
productId: '33',
path: '333',
},
]
stories.add('DeviceConnect', () => (
<DeviceConnect
accountName={text('accountName', 'Test Account')}
coinType={select('coinType', [0, 1, 145, 156, 2, 3, 5], 0)}
appOpened={select('appOpened', ['', 'success', 'fail'], '')}
devices={devices.slice(0, select('devices', [0, 1, 2, 3], 0))}
deviceSelected={devices[select('deviceSelected', ['', 0, 1, 2], '')] || null}
onChangeDevice={action('onChangeDevice')}
/>
))

152
src/components/DeviceMonitNew/index.js

@ -0,0 +1,152 @@
// @flow
import { PureComponent } from 'react'
import { connect } from 'react-redux'
import { ipcRenderer } from 'electron'
import type { Account } from '@ledgerhq/wallet-common/lib/types'
import type { Device, Devices } from 'types/common'
import { sendEvent } from 'renderer/events'
import { getDevices } from 'reducers/devices'
const mapStateToProps = state => ({
devices: getDevices(state),
})
type DeviceStatus =
| 'unconnected'
| 'connected'
| 'appOpened.success'
| 'appOpened.fail'
| 'appOpened.progress'
type Props = {
coinType: number,
devices: Devices,
deviceSelected: Device | null,
account?: Account,
onStatusChange?: DeviceStatus => void,
render?: Function,
}
type State = {
status: DeviceStatus,
}
class DeviceMonit extends PureComponent<Props, State> {
state = {
status: this.props.deviceSelected ? 'connected' : 'unconnected',
}
componentDidMount() {
ipcRenderer.on('msg', this.handleMsgEvent)
if (this.props.deviceSelected !== null) {
this.checkAppOpened()
}
}
componentWillReceiveProps(nextProps) {
const { status } = this.state
const { deviceSelected, devices } = this.props
const { devices: nextDevices, deviceSelected: nextDeviceSelected } = nextProps
if (status === 'unconnected' && !deviceSelected && nextDeviceSelected) {
this.handleStatusChange('connected')
}
if (status !== 'unconnected' && devices !== nextDevices) {
const isConnected = nextDevices.find(d => d === nextDeviceSelected)
if (!isConnected) {
this.handleStatusChange('unconnected')
clearTimeout(this._timeout)
}
}
}
componentDidUpdate(prevProps) {
const { deviceSelected } = this.props
const { deviceSelected: prevDeviceSelected } = prevProps
if (prevDeviceSelected !== deviceSelected) {
this.handleStatusChange('appOpened.progress')
this._timeout = setTimeout(this.checkAppOpened, 250)
}
}
componentWillUnmount() {
ipcRenderer.removeListener('msg', this.handleMsgEvent)
clearTimeout(this._timeout)
}
checkAppOpened = () => {
const { deviceSelected, account, coinType } = this.props
if (deviceSelected === null) {
return
}
let options = null
if (account && account.currency) {
options = {
accountPath: account.path,
accountAddress: account.address,
}
}
if (coinType) {
options = {
coinType,
}
}
sendEvent('usb', 'wallet.checkIfAppOpened', {
devicePath: deviceSelected.path,
...options,
})
}
_timeout: any = null
handleStatusChange = status => {
const { onStatusChange } = this.props
this.setState({ status })
onStatusChange && onStatusChange(status)
}
handleMsgEvent = (e, { type, data }) => {
const { deviceSelected } = this.props
if (deviceSelected === null) {
return
}
if (type === 'wallet.checkIfAppOpened.success' && deviceSelected.path === data.devicePath) {
clearTimeout(this._timeout)
this.handleStatusChange('appOpened.success')
}
if (type === 'wallet.checkIfAppOpened.fail' && deviceSelected.path === data.devicePath) {
this.handleStatusChange('appOpened.fail')
this._timeout = setTimeout(this.checkAppOpened, 1e3)
}
}
render() {
const { status } = this.state
const { devices, deviceSelected, render } = this.props
if (render) {
return render({
status,
devices,
deviceSelected: status === 'connected' ? deviceSelected : null,
})
}
return null
}
}
export default connect(mapStateToProps)(DeviceMonit)

41
src/components/modals/AddAccount/01-step-currency.js

@ -0,0 +1,41 @@
// @flow
import React from 'react'
import { listCurrencies } from '@ledgerhq/currencies'
import type { Currency } from '@ledgerhq/currencies/lib/types'
import type { T } from 'types/common'
import get from 'lodash/get'
import Box from 'components/base/Box'
import Label from 'components/base/Label'
import Select from 'components/base/Select'
const currencies = listCurrencies().map(currency => ({
key: currency.coinType,
name: currency.name,
data: currency,
}))
type Props = {
onChangeCurrency: Function,
currency: Currency | null,
t: T,
}
export default (props: Props) => (
<Box flow={1}>
<Label>{props.t('common:currency')}</Label>
<Select
placeholder={props.t('common:chooseWalletPlaceholder')}
onChange={item => props.onChangeCurrency(item.data)}
renderSelected={item => item.name}
items={currencies}
value={
props.currency ? currencies.find(c => c.key === get(props, 'currency.coinType')) : null
}
/>
</Box>
)

36
src/components/modals/AddAccount/02-step-connect-device.js

@ -0,0 +1,36 @@
// @flow
import React from 'react'
import type { Currency } from '@ledgerhq/currencies/lib/types'
import type { Device } from 'types/common'
import DeviceConnect from 'components/DeviceConnect'
import DeviceMonit from 'components/DeviceMonitNew'
type Props = {
currency: Currency | null,
deviceSelected: Device | null,
onChangeDevice: Function,
onStatusChange: Function,
}
export default (props: Props) => (
<DeviceMonit
coinType={props.currency && props.currency.coinType}
deviceSelected={props.deviceSelected}
onStatusChange={props.onStatusChange}
render={({ status, devices, deviceSelected }) => (
<DeviceConnect
coinType={props.currency && props.currency.coinType}
appOpened={
status === 'appOpened.success' ? 'success' : status === 'appOpened.fail' ? 'fail' : null
}
devices={devices}
deviceSelected={deviceSelected}
onChangeDevice={props.onChangeDevice}
/>
)}
/>
)

100
src/components/modals/AddAccount/03-step-import.js

@ -0,0 +1,100 @@
// @flow
import React, { Fragment } from 'react'
import styled from 'styled-components'
import { getDefaultUnitByCoinType } from '@ledgerhq/currencies'
import type { Currency } from '@ledgerhq/currencies/lib/types'
import type { Account } from '@ledgerhq/wallet-common/lib/types'
import Box from 'components/base/Box'
import AccountCard from 'components/DashboardPage/AccountCard'
const AccountsContainer = styled(Box).attrs({
horizontal: true,
m: -2,
})`
flex-wrap: wrap;
`
const AccountItemWrapper = styled(Box).attrs({
p: 2,
})`
width: 50%;
`
const AccountItem = styled(AccountCard)`
${p => p.selected && `box-shadow: inset 0 0 0 1px ${p.theme.colors.wallet};`};
`
type Props = {
accountsImport: Object,
archivedAccounts: Account[],
currency: Currency | null,
importProgress: boolean,
onSelectAccount?: Function,
selectedAccounts?: Array<number>,
}
function StepImport(props: Props) {
const hasAccountsImports = Object.keys(props.accountsImport).length > 0
const unit = props.currency !== null && getDefaultUnitByCoinType(props.currency.coinType)
return (
<Box>
{props.importProgress ? (
<Box alignItems="center">In progress...</Box>
) : (
hasAccountsImports && <Box mb={-2}>Accounts</Box>
)}
{hasAccountsImports && (
<AccountsContainer pt={5}>
{Object.keys(props.accountsImport).map(k => {
const a = props.accountsImport[k]
return (
<AccountItemWrapper key={a.id}>
<AccountItem
selected={props.selectedAccounts && props.selectedAccounts.includes(a.id)}
onClick={props.onSelectAccount && props.onSelectAccount(a.id)}
account={{
...a,
coinType: props.currency && props.currency.coinType,
name: `Account ${a.accountIndex}`,
currency: props.currency,
unit,
}}
counterValue="USD"
daysCount={365}
/>
</AccountItemWrapper>
)
})}
</AccountsContainer>
)}
{!props.importProgress &&
props.archivedAccounts.length > 0 && (
<Fragment>
<Box pb={3}>Archived accounts</Box>
<AccountsContainer>
{props.archivedAccounts.map(a => (
<AccountItemWrapper key={a.id}>
<AccountItem
selected={props.selectedAccounts && props.selectedAccounts.includes(a.id)}
onClick={props.onSelectAccount && props.onSelectAccount(a.id)}
account={a}
counterValue="USD"
daysCount={365}
/>
</AccountItemWrapper>
))}
</AccountsContainer>
</Fragment>
)}
</Box>
)
}
StepImport.defaultProps = {
onSelectAccount: undefined,
selectedAccounts: [],
}
export default StepImport

69
src/components/modals/AddAccount/CreateAccount.js

@ -1,69 +0,0 @@
// @flow
import React, { PureComponent } from 'react'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
import Input from 'components/base/Input'
import Label from 'components/base/Label'
type Props = {
account: Object,
onAddAccount: Function,
}
type State = {
accountName: string,
}
class CreateAccount extends PureComponent<Props, State> {
state = {
accountName: '',
}
handleCreateAccount = (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
const { accountName } = this.state
const { onAddAccount, account } = this.props
if (accountName.trim() === '') {
return
}
onAddAccount({
...account,
name: accountName,
})
}
handleChangeInput = (value: string) =>
this.setState({
accountName: value,
})
render() {
const { accountName } = this.state
return (
<Box>
<Box>Create Account</Box>
<form onSubmit={this.handleCreateAccount}>
<Box flow={3}>
<Box flow={1}>
<Label>Account name</Label>
<Input value={accountName} onChange={this.handleChangeInput} />
</Box>
<Box horizontal justifyContent="flex-end">
<Button primary type="submit">
Create
</Button>
</Box>
</Box>
</form>
</Box>
)
}
}
export default CreateAccount

131
src/components/modals/AddAccount/ImportAccounts.js

@ -1,131 +0,0 @@
// @flow
import React, { PureComponent } from 'react'
import { translate } from 'react-i18next'
import type { Account } from '@ledgerhq/wallet-common/lib/types'
import type { T } from 'types/common'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
import CheckBox from 'components/base/CheckBox'
import FormattedVal from 'components/base/FormattedVal'
import Input from 'components/base/Input'
type Props = {
t: T,
accounts: Account[],
onImportAccounts: Function,
}
type State = {
accountsSelected: Array<string>,
accountsName: Object,
}
class ImportAccounts extends PureComponent<Props, State> {
state = {
accountsSelected: [],
accountsName: this.props.accounts.reduce((result, value, index) => {
result[value.id] = {
placeholder: this.props.t(`addAccount:import.placeholder`, {
index: index + 1,
}),
}
return result
}, {}),
}
handleSelectAccount = (id: string, selected: boolean) => () =>
this.setState(prev => ({
accountsSelected: selected
? prev.accountsSelected.filter(v => v !== id)
: [...prev.accountsSelected, id],
}))
handleChangeInput = (id: string) => (value: string) =>
this.setState(prev => ({
accountsName: {
...prev.accountsName,
[id]: {
...prev.accountsName[id],
value,
},
},
}))
handleImportAccounts = (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
const { accounts, onImportAccounts } = this.props
const { accountsSelected, accountsName } = this.state
const importAccounts = accountsSelected.map(id => ({
...accounts.find(a => a.id === id),
name: accountsName[id].value || accountsName[id].placeholder,
}))
onImportAccounts(importAccounts)
}
render() {
const { accounts } = this.props
const { accountsSelected, accountsName } = this.state
const canImportAccounts = accountsSelected.length > 0
return (
<Box>
<Box>Import Accounts</Box>
<form onSubmit={this.handleImportAccounts}>
<Box flow={3}>
{accounts.map(account => {
const selected = accountsSelected.includes(account.id)
const accountName = accountsName[account.id]
return (
<Box key={account.id} horizontal flow={10}>
<Box>
<CheckBox
isChecked={selected}
onChange={this.handleSelectAccount(account.id, selected)}
/>
</Box>
<Box grow>
<Box>
<Input
type="text"
disabled={!selected}
placeholder={accountName.placeholder}
value={accountName.value || ''}
onChange={this.handleChangeInput(account.id)}
/>
</Box>
<Box>
Balance:{' '}
<FormattedVal
alwaysShowSign={false}
color="dark"
unit={account.unit}
showCode
val={account.balance}
/>
</Box>
<Box>Operations: {account.operations.length}</Box>
</Box>
</Box>
)
})}
<Box horizontal justifyContent="flex-end">
<Button primary disabled={!canImportAccounts} type="submit">
Import
</Button>
</Box>
</Box>
</form>
</Box>
)
}
}
export default translate()(ImportAccounts)

39
src/components/modals/AddAccount/RestoreAccounts.js

@ -1,39 +0,0 @@
// @flow
import React from 'react'
import styled from 'styled-components'
import type { Account } from '@ledgerhq/wallet-common/lib/types'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
import Text from 'components/base/Text'
const Container = styled(Box)`
border: 1px solid ${p => p.theme.colors.alertRed};
`
type Props = {
archivedAccounts: Account[],
updateAccount: Function,
}
function RestoreAccounts(props: Props) {
const { archivedAccounts, updateAccount } = props
return (
<Container>
<Text fontSize={3} fontWeight="bold">
{'Restore account'}
</Text>
{archivedAccounts.map(account => (
<Box key={account.id} horizontal flow={2} alignItems="center">
<Text>{account.name}</Text>
<Button primary onClick={() => updateAccount({ ...account, archived: false })}>
{'restore'}
</Button>
</Box>
))}
</Container>
)
}
export default RestoreAccounts

506
src/components/modals/AddAccount/index.js

@ -5,19 +5,17 @@ import { connect } from 'react-redux'
import { compose } from 'redux'
import { translate } from 'react-i18next'
import { ipcRenderer } from 'electron'
import differenceBy from 'lodash/differenceBy'
import { listCurrencies, getDefaultUnitByCoinType } from '@ledgerhq/currencies'
import type { Account } from '@ledgerhq/wallet-common/lib/types'
import { getDefaultUnitByCoinType } from '@ledgerhq/currencies'
import type { Account } from '@ledgerhq/wallet-common/lib/types'
import type { Currency } from '@ledgerhq/currencies'
import { MODAL_ADD_ACCOUNT } from 'config/constants'
import type { Device, T } from 'types/common'
import { MODAL_ADD_ACCOUNT } from 'config/constants'
import { closeModal } from 'reducers/modals'
import { canCreateAccount, getAccounts, getArchivedAccounts } from 'reducers/accounts'
import { getCurrentDevice } from 'reducers/devices'
import { sendEvent } from 'renderer/events'
import { addAccount, updateAccount } from 'actions/accounts'
@ -25,97 +23,32 @@ import { fetchCounterValues } from 'actions/counterValues'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
import FormattedVal from 'components/base/FormattedVal'
import Label from 'components/base/Label'
import Modal, { ModalBody } from 'components/base/Modal'
import Select from 'components/base/Select'
import Text from 'components/base/Text'
import CreateAccount from './CreateAccount'
import ImportAccounts from './ImportAccounts'
import RestoreAccounts from './RestoreAccounts'
const currencies = listCurrencies().map(currency => ({
key: currency.coinType,
name: currency.name,
data: currency,
}))
const Steps = {
chooseCurrency: (props: Object) => (
<form onSubmit={props.onSubmit}>
<Box flow={3}>
<Box flow={1}>
<Label>{props.t('common:currency')}</Label>
<Select
placeholder={props.t('common:chooseWalletPlaceholder')}
onChange={item => props.onChangeCurrency(item.data)}
renderSelected={item => item.name}
items={currencies}
value={props.currency ? currencies.find(c => c.key === props.currency.coinType) : null}
/>
</Box>
<Box horizontal justifyContent="flex-end">
{props.fetchingCounterValues ? (
'Fetching counterValues...'
) : (
<Button primary type="submit">
{props.t('addAccount:title')}
</Button>
)}
</Box>
</Box>
</form>
),
connectDevice: (props: Object) => (
<Box>
<Box>Connect your Ledger: {props.connected ? 'ok' : 'ko'}</Box>
<Box>Start {props.currency.name} App on your Ledger: ko</Box>
</Box>
),
inProgress: ({ progress, unit }: Object) => (
<Box>
In progress.
{progress !== null && (
<Box>
<Box>Account: {progress.account}</Box>
<Box>
Balance:{' '}
<FormattedVal
alwaysShowSign={false}
color="dark"
unit={unit}
showCode
val={progress.balance || 0}
/>
</Box>
<Box>Operations: {progress.operations || 0}</Box>
{progress.success && <Box>Finish ! Next account in progress...</Box>}
</Box>
)}
</Box>
),
listAccounts: (props: Object) => {
const { accounts, archivedAccounts } = props
const emptyAccounts = accounts.filter(account => account.operations.length === 0)
const existingAccounts = accounts.filter(account => account.operations.length > 0)
const canCreateAccount = props.canCreateAccount && emptyAccounts.length === 1
const newAccount = emptyAccounts[0]
return (
<Box flow={10}>
<ImportAccounts {...props} accounts={existingAccounts} />
{!!archivedAccounts.length && <RestoreAccounts {...props} accounts={archivedAccounts} />}
{canCreateAccount ? (
<CreateAccount {...props} account={newAccount} />
) : (
<Box>{`You can't create new account`}</Box>
)}
</Box>
)
},
}
import Breadcrumb from 'components/Breadcrumb'
import Modal, { ModalContent, ModalTitle, ModalFooter, ModalBody } from 'components/base/Modal'
import StepCurrency from './01-step-currency'
import StepConnectDevice from './02-step-connect-device'
import StepImport from './03-step-import'
const GET_STEPS = t => [
{ label: t('addAccount:steps.currency.title'), Comp: StepCurrency },
{ label: t('addAccount:steps.connectDevice.title'), Comp: StepConnectDevice },
{ label: t('addAccount:steps.importProgress.title'), Comp: StepImport },
{ label: t('addAccount:steps.importAccounts.title'), Comp: StepImport },
]
const mapStateToProps = state => ({
accounts: getAccounts(state),
archivedAccounts: getArchivedAccounts(state),
canCreateAccount: canCreateAccount(state),
})
type Step = 'chooseCurrency' | 'connectDevice' | 'inProgress' | 'listAccounts'
const mapDispatchToProps = {
addAccount,
closeModal,
fetchCounterValues,
updateAccount,
}
type Props = {
accounts: Account[],
@ -123,251 +56,324 @@ type Props = {
archivedAccounts: Account[],
canCreateAccount: boolean,
closeModal: Function,
counterValues: Object,
currentDevice: Device | null,
fetchCounterValues: Function,
t: T,
updateAccount: Function,
}
type State = {
accounts: Account[],
accountsImport: Object,
currency: Currency | null,
deviceSelected: Device | null,
fetchingCounterValues: boolean,
progress: null | Object,
step: Step,
selectedAccounts: Array<number>,
status: null | string,
stepIndex: number,
}
const mapStateToProps = state => ({
accounts: getAccounts(state),
archivedAccounts: getArchivedAccounts(state),
canCreateAccount: canCreateAccount(state),
counterValues: state.counterValues,
currentDevice: getCurrentDevice(state),
})
const mapDispatchToProps = {
addAccount,
closeModal,
fetchCounterValues,
updateAccount,
}
const defaultState = {
step: 'chooseCurrency',
progress: null,
fetchingCounterValues: false,
const INITIAL_STATE = {
accountsImport: {},
currency: null,
accounts: [],
deviceSelected: null,
fetchingCounterValues: false,
selectedAccounts: [],
status: null,
stepIndex: 0,
}
class AddAccountModal extends PureComponent<Props, State> {
state = {
...defaultState,
}
state = INITIAL_STATE
componentDidMount() {
ipcRenderer.on('msg', this.handleMsgEvent)
}
componentWillReceiveProps(nextProps) {
if (nextProps.accounts) {
this.setState(prev => ({
accounts: differenceBy(prev.accounts, nextProps.accounts, 'id'),
}))
}
}
componentDidUpdate() {
const { step } = this.state
const { currentDevice } = this.props
async componentWillUpdate(nextProps, nextState) {
const { fetchingCounterValues, stepIndex } = this.state
const { stepIndex: nextStepIndex } = nextState
if (step === 'connectDevice' && currentDevice !== null) {
this.getWalletInfos()
} else {
clearTimeout(this._timeout)
if (!fetchingCounterValues && stepIndex === 0 && nextStepIndex === 1) {
await this.fetchCounterValues()
}
}
componentWillUnmount() {
this.killProcess()
ipcRenderer.removeListener('msg', this.handleMsgEvent)
clearTimeout(this._timeout)
}
getWalletInfos() {
const { currentDevice, accounts } = this.props
const { currency } = this.state
importsAccounts() {
const { accounts } = this.props
const { deviceSelected, currency } = this.state
if (currentDevice === null || currency === null) {
if (deviceSelected === null || currency === null) {
return
}
sendEvent('usb', 'wallet.getAccounts', {
pathDevice: currentDevice.path,
pathDevice: deviceSelected.path,
coinType: currency.coinType,
currentAccounts: accounts.map(acc => acc.id),
})
}
getStepProps() {
const { currentDevice, archivedAccounts, canCreateAccount, updateAccount, t } = this.props
const { currency, step, progress, accounts, fetchingCounterValues } = this.state
async fetchCounterValues() {
const { fetchCounterValues } = this.props
const { currency } = this.state
const props = (predicate, props) => (predicate ? props : {})
if (!currency) {
return
}
return {
...props(step === 'chooseCurrency', {
currency,
fetchingCounterValues,
onChangeCurrency: this.handleChangeCurrency,
onSubmit: this.handleSubmit,
t,
}),
...props(step === 'connectDevice', {
t,
connected: currentDevice !== null,
currency,
}),
...props(step === 'inProgress', {
t,
progress,
unit: currency !== null && getDefaultUnitByCoinType(currency.coinType),
}),
...props(step === 'listAccounts', {
t,
accounts,
archivedAccounts,
canCreateAccount,
updateAccount,
onAddAccount: this.handleAddAccount,
onImportAccounts: this.handleImportAccounts,
}),
this.setState({
fetchingCounterValues: true,
stepIndex: 0,
})
await fetchCounterValues(currency.coinType)
this.setState({
fetchingCounterValues: false,
stepIndex: 1,
})
}
canNext = () => {
const { stepIndex } = this.state
if (stepIndex === 0) {
const { currency } = this.state
return currency !== null
}
if (stepIndex === 1) {
const { deviceSelected, status } = this.state
return deviceSelected !== null && status === 'appOpened.success'
}
if (stepIndex === 3) {
const { selectedAccounts } = this.state
return selectedAccounts.length > 0
}
return false
}
_steps = GET_STEPS(this.props.t)
handleMsgEvent = (e, { data, type }) => {
const { accountsImport, currency } = this.state
const { addAccount } = this.props
if (type === 'wallet.getAccounts.start') {
this._pid = data.pid
}
if (type === 'wallet.getAccounts.progress') {
this.setState(prev => ({
step: 'inProgress',
progress:
prev.progress === null
? data
: prev.progress.success
? data
: {
...prev.progress,
stepIndex: 2,
accountsImport: {
...(data !== null
? {
[data.id]: {
...data,
name: `Account ${data.accountIndex + 1}`,
},
}
: {}),
...prev.accountsImport,
},
}))
}
if (type === 'wallet.getAccounts.fail') {
this._timeout = setTimeout(() => this.getWalletInfos(), 1e3)
if (currency && data && data.finish) {
const { accountIndex, finish, ...account } = data
addAccount({
...account,
// As data is passed inside electron event system,
// dates are converted to their string equivalent
//
// so, quick & dirty way to put back Date objects
operations: account.operations.map(op => ({
...op,
date: new Date(op.date),
})),
name: `Account ${accountIndex + 1}`,
archived: true,
currency,
unit: getDefaultUnitByCoinType(currency.coinType),
})
}
}
if (type === 'wallet.getAccounts.success') {
// As data is passed inside electron event system,
// dates are converted to their string equivalent
//
// so, quick & dirty way to put back Date objects
const parsedData = data.map(account => ({
...account,
operations: account.operations.map(op => ({
...op,
date: new Date(op.date),
})),
}))
this.setState({
accounts: parsedData,
step: 'listAccounts',
selectedAccounts: Object.keys(accountsImport).map(k => accountsImport[k].id),
stepIndex: 3,
})
}
}
handleAddAccount = account => this.addAccount(account)
handleChangeDevice = d => this.setState({ deviceSelected: d })
handleImportAccounts = accounts => accounts.forEach(account => this.addAccount(account))
handleSelectAccount = a => () =>
this.setState(prev => ({
selectedAccounts: prev.selectedAccounts.includes(a)
? prev.selectedAccounts.filter(x => x !== a)
: [a, ...prev.selectedAccounts],
}))
handleChangeCurrency = (currency: Currency) => this.setState({ currency })
handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
handleChangeStatus = status => this.setState({ status })
const { fetchCounterValues } = this.props
const { currency } = this.state
handleImportAccount = () => {
const { archivedAccounts, updateAccount } = this.props
const { selectedAccounts } = this.state
if (currency !== null) {
this.setState({
fetchingCounterValues: true,
})
const accounts = archivedAccounts.filter(a => selectedAccounts.includes(a.id))
await fetchCounterValues([currency])
accounts.forEach(a =>
updateAccount({
...a,
archived: false,
}),
)
this.setState({
fetchingCounterValues: false,
step: 'connectDevice',
})
this.setState({
selectedAccounts: [],
})
}
handleNextStep = () => {
const { stepIndex } = this.state
if (stepIndex >= this._steps.length - 1) {
return
}
this.setState({ stepIndex: stepIndex + 1 })
}
handleClose = () => {
sendEvent('msg', 'kill.process', {
pid: this._pid,
})
handleReset = () => {
this.killProcess()
clearTimeout(this._timeout)
this.setState(INITIAL_STATE)
}
handleHide = () =>
this.setState({
...defaultState,
killProcess = () =>
sendEvent('msg', 'kill.process', {
pid: this._pid,
})
addAccount = account => {
const { currency } = this.state
const { addAccount } = this.props
_timeout = undefined
_pid = null
if (currency === null) {
return
renderStep() {
const { accounts, archivedAccounts, t } = this.props
const { stepIndex, currency, accountsImport, deviceSelected, selectedAccounts } = this.state
const step = this._steps[stepIndex]
if (!step) {
return null
}
const { Comp } = step
addAccount({
...account,
coinType: currency.coinType,
const props = (predicate, props) => (predicate ? props : {})
const stepProps = {
t,
currency,
unit: getDefaultUnitByCoinType(currency.coinType),
})
...props(stepIndex === 0, {
onChangeCurrency: this.handleChangeCurrency,
}),
...props(stepIndex === 1, {
deviceSelected,
onStatusChange: this.handleChangeStatus,
onChangeDevice: this.handleChangeDevice,
}),
...props(stepIndex === 2, {
accountsImport,
importProgress: true,
}),
...props(stepIndex === 3, {
accountsImport: Object.keys(accountsImport).reduce((result, k) => {
const account = accountsImport[k]
const existingAccount = accounts.find(a => a.id === account.id)
if (!existingAccount || (existingAccount && existingAccount.archived)) {
result[account.id] = account
}
return result
}, {}),
archivedAccounts: archivedAccounts.filter(a => !accountsImport[a.id]),
importProgress: false,
onSelectAccount: this.handleSelectAccount,
selectedAccounts,
}),
}
return <Comp {...stepProps} />
}
_timeout = undefined
_pid = null
renderButton() {
const { t } = this.props
const { fetchingCounterValues, stepIndex, selectedAccounts } = this.state
let onClick
switch (stepIndex) {
case 1:
onClick = () => {
this.handleNextStep()
this.importsAccounts()
}
break
case 3:
onClick = this.handleImportAccount
break
default:
onClick = this.handleNextStep
}
const props = {
primary: true,
disabled: fetchingCounterValues || !this.canNext(),
onClick,
children: fetchingCounterValues
? 'Fetching counterValues...'
: stepIndex === 3
? t('addAccount:steps.importAccounts.cta', {
count: selectedAccounts.length,
})
: t('common:next'),
}
return <Button {...props} />
}
render() {
const { step } = this.state
const { t } = this.props
const { stepIndex } = this.state
return (
<Modal
name={MODAL_ADD_ACCOUNT}
preventBackdropClick={step !== 'chooseCurrency'}
onClose={this.handleClose}
onHide={this.handleHide}
render={({ onClose }) => {
const Step = Steps[step]
return (
<ModalBody onClose={onClose} flow={3}>
<Text fontSize={6} color="graphite">
{t('addAccount:title')}
</Text>
<Step {...this.getStepProps()} />
</ModalBody>
)
}}
onHide={this.handleReset}
render={({ onClose }) => (
<ModalBody onClose={onClose}>
<ModalTitle>{t('addAccount:title')}</ModalTitle>
<ModalContent>
<Breadcrumb mb={6} mt={2} currentStep={stepIndex} items={this._steps} />
{this.renderStep()}
</ModalContent>
{stepIndex !== 2 && (
<ModalFooter>
<Box horizontal alignItems="center" justifyContent="flex-end">
{this.renderButton()}
</Box>
</ModalFooter>
)}
</ModalBody>
)}
/>
)
}

2
src/components/modals/Send/Footer.js

@ -23,7 +23,7 @@ type Props = {
function Footer({ account, amount, t, onNext, canNext, counterValue }: Props) {
return (
<ModalFooter horizontal align="center">
<ModalFooter horizontal alignItems="center">
<Box grow>
<Label>{t('send:totalSpent')}</Label>
<Box horizontal flow={2} align="center">

16
src/helpers/btc.js

@ -30,7 +30,6 @@ export function computeOperation(addresses: Array<string>) {
.filter(i => addresses.includes(i.address))
.reduce((acc, cur) => acc + cur.value, 0)
const amount = outputVal - inputVal
console.warn('assiging a fake account id and blockHeight to operation') // eslint-disable-line no-console
return {
id: t.hash,
hash: t.hash,
@ -167,6 +166,7 @@ export async function getAccount({
if (hasOperations) {
const newOperations = txs.map(computeOperation(allAddresses))
const txHashs = operations.map(t => t.id)
balance = newOperations
@ -174,11 +174,13 @@ export async function getAccount({
.reduce((result, v) => result + v.amount, balance)
lastAddress = getLastAddress(addresses, txs[0])
operations = uniqBy([...operations, ...newOperations], t => t.id)
onProgress({
balance,
operations: operations.length,
operations,
balanceByDay: getBalanceByDay(operations),
})
return nextPath(index + (GAP_LIMIT_ADDRESSES - 1))
@ -192,14 +194,22 @@ export async function getAccount({
})
: getAddress({ type: 'external', index: 0 })
return {
const account = {
...nextAddress,
coinType,
addresses: operations.length > 0 ? allAddresses : [],
balance,
balanceByDay: getBalanceByDay(operations),
rootPath,
operations,
}
onProgress({
...account,
finish: true,
})
return account
})
if (allAddresses.length === 0 && currentIndex > 0) {

12
src/icons/ExclamationCircle.js

@ -0,0 +1,12 @@
// @flow
import React from 'react'
export default ({ size, ...p }: { size: number }) => (
<svg viewBox="0 0 16 16" height={size} width={size} {...p}>
<path
fill="currentColor"
d="M8 .25C3.72009375.25.25 3.72134375.25 8c0 4.2811563 3.47009375 7.75 7.75 7.75 4.2799062 0 7.75-3.4688437 7.75-7.75C15.75 3.72134375 12.2799062.25 8 .25zm0 14c-3.454125 0-6.25-2.7947187-6.25-6.25 0-3.45296875 2.796-6.25 6.25-6.25 3.4528437 0 6.25 2.79596875 6.25 6.25 0 3.4540625-2.7947187 6.25-6.25 6.25zM9.3125 11c0 .7237187-.58878125 1.3125-1.3125 1.3125S6.6875 11.7237187 6.6875 11 7.27628125 9.6875 8 9.6875 9.3125 10.2762813 9.3125 11zM6.7696875 4.39371875l.2125 4.25C6.99215625 8.8433125 7.15690625 9 7.35671875 9h1.2865625c.1998125 0 .3645625-.1566875.37453125-.35628125l.2125-4.25C9.24103125 4.17953125 9.07025 4 8.85578125 4h-1.7115625c-.21446875 0-.38525.17953125-.37453125.39371875z"
/>
</svg>
)

9
src/icons/Loader.js

@ -0,0 +1,9 @@
// @flow
import React from 'react'
export default ({ size, ...p }: { size: number }) => (
<svg viewBox="0 0 36 36" height={size} width={size} {...p}>
<path d="M16.735 2.8c0-.69864021.5663598-1.265 1.265-1.265.6986402 0 1.265.56635979 1.265 1.265v6.4c0 .69864021-.5663598 1.265-1.265 1.265-.6986402 0-1.265-.56635979-1.265-1.265V2.8zm11.118533 3.55748685c.4940132-.49401323 1.2949669-.49401323 1.7889802 0 .4940132.49401323.4940132 1.29496693 0 1.78898016l-4.5254834 4.52548339c-.4940133.4940132-1.294967.4940132-1.7889802 0-.4940132-.4940132-.4940132-1.2949669 0-1.7889802l4.5254834-4.52548335zM33.2 16.735c.6986402 0 1.265.5663598 1.265 1.265 0 .6986402-.5663598 1.265-1.265 1.265h-6.4c-.6986402 0-1.265-.5663598-1.265-1.265 0-.6986402.5663598-1.265 1.265-1.265h6.4zm-3.5574868 11.118533c.4940132.4940132.4940132 1.2949669 0 1.7889802-.4940133.4940132-1.294967.4940132-1.7889802 0l-4.5254834-4.5254834c-.4940132-.4940133-.4940132-1.294967 0-1.7889802.4940132-.4940132 1.2949669-.4940132 1.7889802 0l4.5254834 4.5254834zM19.265 33.2c0 .6986402-.5663598 1.265-1.265 1.265-.6986402 0-1.265-.5663598-1.265-1.265v-6.4c0-.6986402.5663598-1.265 1.265-1.265.6986402 0 1.265.5663598 1.265 1.265v6.4zM8.14646701 29.6425132c-.49401323.4940132-1.29496693.4940132-1.78898016 0-.49401323-.4940133-.49401323-1.294967 0-1.7889802l4.52548335-4.5254834c.4940133-.4940132 1.294967-.4940132 1.7889802 0 .4940132.4940132.4940132 1.2949669 0 1.7889802l-4.52548339 4.5254834zM2.8 19.265c-.69864021 0-1.265-.5663598-1.265-1.265 0-.6986402.56635979-1.265 1.265-1.265h6.4c.69864021 0 1.265.5663598 1.265 1.265 0 .6986402-.56635979 1.265-1.265 1.265H2.8zM6.35748685 8.14646701c-.49401323-.49401323-.49401323-1.29496693 0-1.78898016.49401323-.49401323 1.29496693-.49401323 1.78898016 0l4.52548339 4.52548335c.4940132.4940133.4940132 1.294967 0 1.7889802-.4940132.4940132-1.2949669.4940132-1.7889802 0L6.35748685 8.14646701z" />
</svg>
)

14
src/icons/Usb.js

@ -0,0 +1,14 @@
// @flow
import React from 'react'
export default ({ size, ...p }: { size: number }) => (
<svg viewBox="0 0 36 36" height={size} width={size} {...p}>
<g fill="currentColor">
<path d="M13.62711864 10.13333333h8.77966106c.346348 0 .6271186-.2760911.6271186-.61666666v-7.4c0-.3405756-.2807706-.61666667-.6271186-.61666667h-8.77966106C13.28077058 1.5 13 1.77609107 13 2.11666667v7.4c0 .34057556.28077058.61666666.62711864.61666666zm.62711865-7.4H21.779661V8.9h-7.52542371V2.73333333z" />
<path d="M23.4922034 23.0333333H12.3050339l-.05079661-12.79999997H23.5423729l-.0501695 12.79999997zm1.3044068-.0666667V10.23333333C24.7966102 9.5517411 24.2355175 9 23.5423729 9H12.25423729C11.56159999 9 11 9.55185858 11 10.23333333V22.9666666c0 .7179702.58202986 1.3000001 1.30000007 1.3000001H23.4966101c.7179702 0 1.3000001-.5820299 1.3000001-1.3000001z" />
<path d="M19.71696115 24.23333333V26.7h-3.76271186v-2.46666667h3.76271186zm0 3.7c.69314464 0 1.25423729-.5517411 1.25423729-1.23333333v-3.08333333c0-.34057557-.28077061-.61666667-.62711864-.61666667h-5.01694916c-.34634803 0-.62711864.2760911-.62711864.61666667V26.7c0 .68147476.56159999 1.23333333 1.25423729 1.23333333h3.76271186z" />
<path d="M16.52711233 34.8666667h2.50847458c.34634803 0 .62711864-.2760911.62711864-.6166667v-6.63333333c0-.34057557-.28077061-.61666667-.62711864-.61666667h-2.50847458c-.34634803 0-.62711864.2760911-.62711864.61666667V34.25c0 .3405756.28077061.6166667.62711864.6166667zm.62711865-6.63333337h1.25423729v5.39999997h-1.25423729v-5.39999997zM15.399994 4.61666667v2.46666666c0 .3405756.28077061.61666667.62711864.61666667.34634804 0 .62711865-.27609107.62711865-.61666667V4.61666667c0-.3405756-.28077061-.61666667-.62711865-.61666667-.34634803 0-.62711864.27609107-.62711864.61666667zm4 0v2.46666666c0 .3405756.28077061.61666667.62711864.61666667.34634804 0 .62711865-.27609107.62711865-.61666667V4.61666667c0-.3405756-.28077061-.61666667-.62711865-.61666667-.34634803 0-.62711864.27609107-.62711864.61666667z" />
</g>
</svg>
)

12
src/icons/device/Blue.js

@ -0,0 +1,12 @@
// @flow
import React from 'react'
export default ({ size, ...p }: { size: number }) => (
<svg viewBox="0 0 36 36" height={size} width={size} {...p}>
<path
fill="currentColor"
d="M8.78055577 5C8.34946672 5 8 5.34946672 8 5.78055577V30.2194442C8 30.6505333 8.34946672 31 8.78055577 31H27.2194442C27.6505333 31 28 30.6505333 28 30.2194442V5.78055577C28 5.34946672 27.6505333 5 27.2194442 5H8.78055577zm0-2H27.2194442C28.7551028 3 30 4.24489722 30 5.78055577V30.2194442C30 31.7551028 28.7551028 33 27.2194442 33H8.78055577C7.24489722 33 6 31.7551028 6 30.2194442V5.78055577C6 4.24489722 7.24489722 3 8.78055577 3zm3.33166653 5h11.7755554C24.5020411 8 25 8.49795889 25 9.11222231V26.8877777C25 27.5020411 24.5020411 28 23.8877777 28H12.1122223C11.4979589 28 11 27.5020411 11 26.8877777V9.11222231C11 8.49795889 11.4979589 8 12.1122223 8zM12.5 9.5v17h11v-17h-11z"
/>
</svg>
)

12
src/icons/device/NanoS.js

@ -0,0 +1,12 @@
// @flow
import React from 'react'
export default ({ size, ...p }: { size: number }) => (
<svg viewBox="0 0 9 34" height={size} width={size} {...p}>
<path
fill="currentColor"
d="M8 17.451c-.51562-.68629-1.2037-1.2356-2-1.5835V1.9995H2v13.868c-.79625.3479-1.4844.89718-2 1.5835V1.44C0 .64471.64471 0 1.44 0h5.12C7.35529 0 8 .64471 8 1.44v.75355h.40706c.32747 0 .59294.26547.59294.59294v1.0077c0 .32747-.26547.59294-.59294.59294H8v6.5806h.40706c.32747 0 .59294.26547.59294.59294v1.0077c0 .32747-.26547.59294-.59294.59294H8v4.2898zm-4 6.4844c-.55228 0-1-.44772-1-1s.44772-1 1-1 1 .44772 1 1-.44772 1-1 1zm-2 8.0645h4v-11.548c0-1.1046-.89543-2-2-2s-2 .89543-2 2v11.548zm2-15.548c2.2091 0 4 1.7909 4 4v12.363c0 .65494-.53094 1.1859-1.1859 1.1859H1.1859C.53096 34.0008 0 33.46986 0 32.8149v-12.363c0-2.2091 1.7909-4 4-4z"
/>
</svg>
)

2
src/icons/device/index.js

@ -0,0 +1,2 @@
export Blue from './Blue'
export NanoS from './NanoS'

29
src/internals/usb/devices.js

@ -2,9 +2,7 @@
import CommNodeHid from '@ledgerhq/hw-transport-node-hid'
import noop from 'lodash/noop'
import type Transport from '@ledgerhq/hw-transport'
import { APDUS } from 'internals/usb/manager/constants'
import type { IPCSend } from 'types/electron'
export default (send: IPCSend) => ({
@ -16,11 +14,9 @@ export default (send: IPCSend) => ({
if (!e.device) {
return
}
if (e.type === 'add') {
const isValid = await isValidHIDDevice(e.device.path)
if (isValid) {
send('device.add', e.device, { kill: false })
}
send('device.add', e.device, { kill: false })
}
if (e.type === 'remove') {
@ -30,24 +26,3 @@ export default (send: IPCSend) => ({
})
},
})
/**
* Attempt to get firmware infos from device
* If it fails, we consider it is an invalid device
*/
async function isValidHIDDevice(devicePath: string): Promise<boolean> {
try {
const transport: Transport<*> = await CommNodeHid.open(devicePath)
try {
await transport.send(...APDUS.GET_FIRMWARE)
return true
} catch (err) {
// if we are inside an app, the first call should have failed,
// so we try this one
await transport.send(...APDUS.GET_FIRMWARE_FALLBACK)
return true
}
} catch (err) {
return false
}
}

13
src/internals/usb/wallet/accounts.js

@ -9,7 +9,7 @@ import Btc from '@ledgerhq/hw-app-btc'
import { getAccount, getHDNode, networks } from 'helpers/btc'
import { serializeAccounts } from 'reducers/accounts'
type CoinType = 0 | 1
type CoinType = number
async function sleep(delay, callback) {
if (delay !== 0) {
@ -56,10 +56,10 @@ function encodeBase58Check(vchIn) {
return bs58check.encode(Buffer.from(vchIn))
}
function getPath({
export function getPath({
coinType,
account,
segwit,
segwit = true,
}: {
coinType: CoinType,
account?: any,
@ -155,7 +155,8 @@ export default async ({
rootPath: path,
segwit,
onProgress: ({ operations, ...progress }) =>
operations > 0 && onProgress({ account: currentAccount, operations, ...progress }),
operations.length > 0 &&
onProgress({ id: xpub58, accountIndex: currentAccount, operations, ...progress }),
})
const hasOperations = account.operations.length > 0
@ -167,10 +168,6 @@ export default async ({
})
if (hasOperations) {
onProgress({
success: true,
})
const nextAccount = await sleep(nextAccountDelay, () =>
getAllAccounts(currentAccount + 1, accounts),
)

23
src/internals/usb/wallet/index.js

@ -3,7 +3,7 @@
import CommNodeHid from '@ledgerhq/hw-transport-node-hid'
import Btc from '@ledgerhq/hw-app-btc'
import getAllAccounts, { verifyAddress } from './accounts'
import getAllAccounts, { getPath, verifyAddress } from './accounts'
async function getAllAccountsByCoinType({ pathDevice, coinType, currentAccounts, onProgress }) {
const transport = await CommNodeHid.open(pathDevice)
@ -59,11 +59,13 @@ export default (sendEvent: Function) => ({
}
},
checkIfAppOpened: async ({
coinType,
devicePath,
accountPath,
accountAddress,
segwit = true,
}: {
coinType?: number,
devicePath: string,
accountPath: string,
accountAddress: string,
@ -72,14 +74,21 @@ export default (sendEvent: Function) => ({
try {
const transport = await CommNodeHid.open(devicePath)
const btc = new Btc(transport)
const { bitcoinAddress } = await btc.getWalletPublicKey(accountPath, false, segwit)
if (bitcoinAddress === accountAddress) {
sendEvent('wallet.checkIfAppOpened.success')
} else {
throw new Error('Address is different')
if (accountPath) {
const { bitcoinAddress } = await btc.getWalletPublicKey(accountPath, false, segwit)
if (bitcoinAddress === accountAddress) {
sendEvent('wallet.checkIfAppOpened.success', { devicePath })
} else {
throw new Error('Address is different')
}
}
if (coinType) {
await btc.getWalletPublicKey(getPath({ coinType, segwit }), false, segwit)
sendEvent('wallet.checkIfAppOpened.success', { devicePath })
}
} catch (err) {
sendEvent('wallet.checkIfAppOpened.fail')
sendEvent('wallet.checkIfAppOpened.fail', { devicePath })
}
},
})

2
src/reducers/settings.js

@ -12,7 +12,7 @@ export type SettingsState = Object
const defaultState: SettingsState = {
counterValue: 'USD',
language: 'en',
orderAccounts: 'balance|desc',
orderAccounts: 'balance|asc',
password: {
state: false,
},

6
src/types/common.js

@ -1,9 +1,11 @@
// @flow
export type Device = {
vendorId: string,
productId: string,
manufacturer: string,
path: string,
product: string,
productId: string,
vendorId: string,
}
export type Devices = Array<Device>

15
static/i18n/en/addAccount.yml

@ -1,3 +1,14 @@
title: Add account
import:
placeholder: 'Account {{index}}'
steps:
currency:
title: Informations
connectDevice:
title: Connect Device
importProgress:
title: In Progress
importAccounts:
title: Import Accounts
cta_0: Import Account
cta_1: Import {{count}} Account
cta_2: Import {{count}} Accounts

1
static/i18n/en/common.yml

@ -10,3 +10,4 @@ password: Password
editProfile: Edit profile
lockApplication: Lock application
max: Max
next: Next

8
static/i18n/en/deviceConnect.yml

@ -0,0 +1,8 @@
step1:
connect: Connect your <0>Ledger device</0> to your computer and enter your <1>PIN code</1> on your device
choose: We detected {{devicesCount}} devices connected, please select one:
step2:
open: Open <0>{{appName}}</0> App on your device
info: You must use the device associated to the account <0>{{accountName}}</0>

2
webpack/renderer.config.js

@ -6,8 +6,8 @@ const rules = require('./rules')
const config = {
mode: __ENV__,
resolve,
plugins: [...plugins('renderer'), new HardSourceWebpackPlugin()],
resolve,
module: {
rules,
},

4
webpack/rules.js

@ -1,12 +1,16 @@
const babelConfig = require('../babel.config')
const { NODE_ENV } = process.env
module.exports = [
{
test: /\.js$/,
loader: 'babel-loader',
options: {
babelrc: false,
cacheDirectory: NODE_ENV === 'development',
...babelConfig(),
},
exclude: /node_modules/,
},
]

78
yarn.lock

@ -942,9 +942,9 @@
dependencies:
"@ledgerhq/hw-transport" "^4.7.3"
"@ledgerhq/hw-transport-node-hid@^4.7.3":
version "4.7.3"
resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-node-hid/-/hw-transport-node-hid-4.7.3.tgz#e7634d53161cdffed4f602cddca6a7bc34e7b79b"
"@ledgerhq/hw-transport-node-hid@^4.7.6":
version "4.7.6"
resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-node-hid/-/hw-transport-node-hid-4.7.6.tgz#f2bd7c714e359af84377d07dd6431f2aa582e71e"
dependencies:
"@ledgerhq/hw-transport" "^4.7.3"
node-hid "^0.7.2"
@ -3179,7 +3179,7 @@ builder-util@5.6.7:
stat-mode "^0.2.2"
temp-file "^3.1.1"
builder-util@5.7.4, builder-util@^5.6.7, builder-util@^5.7.0:
builder-util@5.7.4, builder-util@^5.6.7, builder-util@^5.7.0, builder-util@^5.7.4:
version "5.7.4"
resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-5.7.4.tgz#d6e9a56e2865f0d0a504a07ea0f8dc35185b4795"
dependencies:
@ -3198,25 +3198,6 @@ builder-util@5.7.4, builder-util@^5.6.7, builder-util@^5.7.0:
stat-mode "^0.2.2"
temp-file "^3.1.1"
builder-util@^5.7.4:
version "5.7.5"
resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-5.7.5.tgz#58f8d2b7a35445c5fb45bff50b39bbed554c9863"
dependencies:
"7zip-bin" "~3.1.0"
app-builder-bin "1.8.4"
bluebird-lst "^1.0.5"
builder-util-runtime "^4.2.0"
chalk "^2.3.2"
debug "^3.1.0"
fs-extra-p "^4.5.2"
is-ci "^1.1.0"
js-yaml "^3.11.0"
lazy-val "^1.0.3"
semver "^5.5.0"
source-map-support "^0.5.4"
stat-mode "^0.2.2"
temp-file "^3.1.1"
builtin-modules@^1.0.0, builtin-modules@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
@ -4865,7 +4846,7 @@ electron-builder-lib@~20.6.2:
semver "^5.5.0"
temp-file "^3.1.1"
electron-builder@^20.8.1:
electron-builder@^20.0.4, electron-builder@^20.8.1:
version "20.8.1"
resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-20.8.1.tgz#3d19607a7f7d3ee7f3e110a6fc66c720ed1d2cc0"
dependencies:
@ -5045,7 +5026,7 @@ electron-webpack@^2.0.1:
webpack-merge "^4.1.2"
yargs "^11.1.0"
electron@1.8.4:
electron@1.8.4, electron@^1.8.2:
version "1.8.4"
resolved "https://registry.yarnpkg.com/electron/-/electron-1.8.4.tgz#cca8d0e6889f238f55b414ad224f03e03b226a38"
dependencies:
@ -5851,9 +5832,9 @@ flatten@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
flow-bin@^0.68.0:
version "0.68.0"
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.68.0.tgz#86c2d14857d306eb2e85e274f2eebf543564f623"
flow-bin@^0.69.0:
version "0.69.0"
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.69.0.tgz#053159a684a6051fcbf0b71a2eb19a9679082da6"
flow-parser@^0.*:
version "0.68.0"
@ -8033,6 +8014,9 @@ ledger-test-library@KhalilBellakrid/ledger-test-library-nodejs#7d37482:
dependencies:
axios "^0.17.1"
bindings "^1.3.0"
electron "^1.8.2"
electron-builder "^20.0.4"
electron-rebuild "^1.7.3"
nan "^2.6.2"
prebuild-install "^2.2.2"
@ -10142,9 +10126,9 @@ react-docgen@^2.20.0:
node-dir "^0.1.10"
recast "^0.12.6"
react-dom@^16.2.0:
version "16.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044"
react-dom@^16.3.0:
version "16.3.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.3.0.tgz#b318e52184188ecb5c3e81117420cca40618643e"
dependencies:
fbjs "^0.8.16"
loose-envify "^1.1.0"
@ -10201,6 +10185,10 @@ react-inspector@^2.2.2:
babel-runtime "^6.26.0"
is-dom "^1.0.9"
react-is@^16.3.0:
version "16.3.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.3.0.tgz#f0e8bfd8c09b480dd610b8639d9ed65c13601224"
react-modal@^3.1.10:
version "3.3.1"
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.3.1.tgz#7355db196482da0c7fa1cbecccf2bdd9bc366b14"
@ -10304,13 +10292,14 @@ react-style-proptype@^3.0.0:
dependencies:
prop-types "^15.5.4"
react-test-renderer@^16.2.0:
version "16.2.0"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.2.0.tgz#bddf259a6b8fcd8555f012afc8eacc238872a211"
react-test-renderer@^16.3.0:
version "16.3.0"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.3.0.tgz#7e88d8cb4c2b95c161b6c6c998991166f74d473e"
dependencies:
fbjs "^0.8.16"
object-assign "^4.1.1"
prop-types "^15.6.0"
react-is "^16.3.0"
react-textarea-autosize@^5.2.1:
version "5.2.1"
@ -10348,6 +10337,15 @@ react@^16.0.0, react@^16.2.0:
object-assign "^4.1.1"
prop-types "^15.6.0"
react@^16.3.0:
version "16.3.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.3.0.tgz#fc5a01c68f91e9b38e92cf83f7b795ebdca8ddff"
dependencies:
fbjs "^0.8.16"
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.0"
reactcss@^1.2.0:
version "1.2.3"
resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd"
@ -12090,9 +12088,9 @@ ua-parser-js@^0.7.9:
version "0.7.17"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
uglify-es@^3.3.4:
version "3.3.9"
resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677"
uglify-es@3.3.7, uglify-es@^3.3.4:
version "3.3.7"
resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.7.tgz#d1249af668666aba7cb1163e277455be9eb393cf"
dependencies:
commander "~2.13.0"
source-map "~0.6.1"
@ -12706,9 +12704,9 @@ webpack@^3.10.0:
webpack-sources "^1.0.1"
yargs "^8.0.2"
webpack@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.3.0.tgz#0b0c1e211311b3995dd25aed47ab46ea658be070"
webpack@^4.4.1:
version "4.4.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.4.1.tgz#b0105789890c28bfce9f392623ef5850254328a4"
dependencies:
acorn "^5.0.0"
acorn-dynamic-import "^3.0.0"

Loading…
Cancel
Save