Pull from dev

This commit is contained in:
mmv 2019-06-20 13:12:47 +04:00
commit 0b3c791a93
49 changed files with 20440 additions and 754 deletions

View File

@ -32,14 +32,14 @@
"dependencies": { "dependencies": {
"@gnosis.pm/safe-contracts": "^1.0.0", "@gnosis.pm/safe-contracts": "^1.0.0",
"@gnosis.pm/util-contracts": "2.0.1", "@gnosis.pm/util-contracts": "2.0.1",
"@material-ui/core": "4.1.0", "@material-ui/core": "4.1.1",
"@material-ui/icons": "4.1.0", "@material-ui/icons": "4.2.0",
"@welldone-software/why-did-you-render": "^3.0.9", "@welldone-software/why-did-you-render": "3.2.1",
"axios": "0.19.0", "axios": "0.19.0",
"bignumber.js": "9.0.0", "bignumber.js": "9.0.0",
"connected-react-router": "^6.3.1", "connected-react-router": "^6.3.1",
"date-fns": "^1.30.1", "date-fns": "^1.30.1",
"final-form": "4.14.1", "final-form": "4.15.0",
"history": "^4.7.2", "history": "^4.7.2",
"immortal-db": "^1.0.2", "immortal-db": "^1.0.2",
"immutable": "^4.0.0-rc.9", "immutable": "^4.0.0-rc.9",
@ -48,9 +48,9 @@
"qrcode.react": "^0.9.3", "qrcode.react": "^0.9.3",
"react": "^16.8.6", "react": "^16.8.6",
"react-dom": "^16.8.6", "react-dom": "^16.8.6",
"react-final-form": "6.1.0", "react-final-form": "6.2.1",
"react-final-form-listeners": "^1.0.2", "react-final-form-listeners": "^1.0.2",
"react-hot-loader": "4.11.0", "react-hot-loader": "4.11.1",
"react-infinite-scroll-component": "^4.5.2", "react-infinite-scroll-component": "^4.5.2",
"react-redux": "7.1.0", "react-redux": "7.1.0",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",
@ -87,14 +87,13 @@
"@babel/preset-flow": "^7.0.0-beta.40", "@babel/preset-flow": "^7.0.0-beta.40",
"@babel/preset-react": "^7.0.0-beta.40", "@babel/preset-react": "^7.0.0-beta.40",
"@sambego/storybook-state": "^1.0.7", "@sambego/storybook-state": "^1.0.7",
"@storybook/addon-actions": "5.1.4", "@storybook/addon-actions": "5.1.8",
"@storybook/addon-knobs": "5.1.4", "@storybook/addon-knobs": "5.1.8",
"@storybook/addon-links": "5.1.4", "@storybook/addon-links": "5.1.8",
"@storybook/react": "5.1.4", "@storybook/react": "5.1.8",
"@testing-library/react": "^8.0.1",
"autoprefixer": "9.6.0", "autoprefixer": "9.6.0",
"babel-core": "^7.0.0-bridge.0", "babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1", "babel-eslint": "10.0.2",
"babel-jest": "24.8.0", "babel-jest": "24.8.0",
"babel-loader": "8.0.6", "babel-loader": "8.0.6",
"babel-plugin-dynamic-import-node": "^2.2.0", "babel-plugin-dynamic-import-node": "^2.2.0",
@ -105,7 +104,7 @@
"detect-port": "^1.2.2", "detect-port": "^1.2.2",
"eslint": "^5.16.0", "eslint": "^5.16.0",
"eslint-config-airbnb": "^17.1.0", "eslint-config-airbnb": "^17.1.0",
"eslint-plugin-flowtype": "3.10.2", "eslint-plugin-flowtype": "3.10.3",
"eslint-plugin-import": "2.17.3", "eslint-plugin-import": "2.17.3",
"eslint-plugin-jest": "22.6.4", "eslint-plugin-jest": "22.6.4",
"eslint-plugin-jsx-a11y": "^6.0.3", "eslint-plugin-jsx-a11y": "^6.0.3",
@ -125,7 +124,7 @@
"postcss-mixins": "^6.2.0", "postcss-mixins": "^6.2.0",
"postcss-simple-vars": "^5.0.2", "postcss-simple-vars": "^5.0.2",
"pre-commit": "^1.2.2", "pre-commit": "^1.2.2",
"prettier-eslint-cli": "^4.7.1", "prettier-eslint-cli": "5.0.0",
"run-with-testrpc": "0.3.1", "run-with-testrpc": "0.3.1",
"storybook-host": "^5.0.3", "storybook-host": "^5.0.3",
"storybook-router": "^0.3.3", "storybook-router": "^0.3.3",
@ -137,7 +136,7 @@
"webpack": "4.34.0", "webpack": "4.34.0",
"webpack-bundle-analyzer": "3.3.2", "webpack-bundle-analyzer": "3.3.2",
"webpack-cli": "3.3.4", "webpack-cli": "3.3.4",
"webpack-dev-server": "3.7.1", "webpack-dev-server": "3.7.2",
"webpack-manifest-plugin": "^2.0.0-rc.2" "webpack-manifest-plugin": "^2.0.0-rc.2"
} }
} }

View File

@ -18,9 +18,10 @@ const SelectInput = ({
formControlProps, formControlProps,
classes, classes,
renderValue, renderValue,
disableError,
...rest ...rest
}: SelectFieldProps) => { }: SelectFieldProps) => {
const showError = ((meta.submitError && !meta.dirtySinceLastSubmit) || meta.error) && meta.touched const showError = ((meta.submitError && !meta.dirtySinceLastSubmit) || meta.error) && meta.touched && !disableError
const inputProps = { const inputProps = {
...restInput, ...restInput,
name, name,

View File

@ -70,4 +70,12 @@ export const inLimit = (limit: number, base: number, baseText: string, symbol: s
return `Should not exceed ${max} ${symbol} (amount to reach ${baseText})` return `Should not exceed ${max} ${symbol} (amount to reach ${baseText})`
} }
export const differentFrom = (diffValue: string) => (value: string) => {
if (value === diffValue.toString()) {
return `Value should be different than ${value}`
}
return undefined
}
export const noErrorsOn = (name: string, errors: Object) => errors[name] === undefined export const noErrorsOn = (name: string, errors: Object) => errors[name] === undefined

View File

@ -24,7 +24,7 @@ class Block extends PureComponent<Props> {
const paddingStyle = padding ? capitalize(padding, 'padding') : undefined const paddingStyle = padding ? capitalize(padding, 'padding') : undefined
return ( return (
<div className={cx(className, 'block', margin, paddingStyle, align)} {...props}> <div className={cx(className, 'block', margin, paddingStyle, align)} {...props}>
{ children } {children}
</div> </div>
) )
} }

View File

@ -10,7 +10,7 @@ type Props = {
type: 'button' | 'submit' | 'reset', type: 'button' | 'submit' | 'reset',
size?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl', size?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl',
weight?: 'light' | 'regular' | 'bolder' | 'bold', weight?: 'light' | 'regular' | 'bolder' | 'bold',
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled', color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled' | 'error',
testId?: string, testId?: string,
} }

View File

@ -74,6 +74,10 @@
color: $disabled; color: $disabled;
} }
.error {
color: $error;
}
.white { .white {
color: white; color: white;
} }

View File

@ -11,7 +11,7 @@ type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4';
type Props = { type Props = {
align?: 'left' | 'center' | 'right', align?: 'left' | 'center' | 'right',
margin?: 'sm' | 'md' | 'lg' | 'xl', margin?: 'sm' | 'md' | 'lg' | 'xl',
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled', color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled' | 'error',
tag: HeadingTag, tag: HeadingTag,
truncate?: boolean, truncate?: boolean,
children: React.Node, children: React.Node,

View File

@ -95,6 +95,10 @@
color: $disabled; color: $disabled;
} }
.error {
color: $error;
}
.white { .white {
color: white; color: white;
} }

View File

@ -13,7 +13,7 @@ type Props = {
children: React.Node, children: React.Node,
color?: 'regular' | 'white', color?: 'regular' | 'white',
className?: string, className?: string,
innerRef: React.ElementRef<any>, innerRef?: React.ElementRef<any>,
} }
const GnosisLink = ({ const GnosisLink = ({

View File

@ -11,7 +11,7 @@ type Props = {
noPadding?: boolean, noPadding?: boolean,
weight?: 'light' | 'regular' | 'bolder' | 'bold', weight?: 'light' | 'regular' | 'bolder' | 'bold',
size?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl', size?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl',
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled', color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled' | 'error',
transform?: 'capitalize' | 'lowercase' | 'uppercase', transform?: 'capitalize' | 'lowercase' | 'uppercase',
children: React.Node, children: React.Node,
dot?: boolean, dot?: boolean,

View File

@ -35,6 +35,10 @@
color: $disabled; color: $disabled;
} }
.error {
color: $error;
}
.white { .white {
color: white; color: white;
} }

View File

@ -1,6 +1,6 @@
// @flow // @flow
import classNames from 'classnames/bind' import classNames from 'classnames/bind'
import React from 'react' import * as React from 'react'
import { capitalize } from '~/utils/css' import { capitalize } from '~/utils/css'
import styles from './index.scss' import styles from './index.scss'
@ -27,7 +27,7 @@ const Row = ({
return ( return (
<div className={rowClassNames} {...props}> <div className={rowClassNames} {...props}>
{ children } {children}
</div> </div>
) )
} }

View File

@ -14,7 +14,7 @@ export const TX_TYPE_EXECUTION = 'execution'
export const executeTransaction = async ( export const executeTransaction = async (
safeInstance: any, safeInstance: any,
to: string, to: string,
valueInWei: number, valueInWei: number | string,
data: string, data: string,
operation: number | string, operation: number | string,
nonce: string | number, nonce: string | number,

View File

@ -1,7 +1,7 @@
// @flow // @flow
import { type Owner } from '~/routes/safe/store/models/owner' import { type Owner } from '~/routes/safe/store/models/owner'
import { List, Map } from 'immutable' import { List, Map } from 'immutable'
import { loadFromStorage, saveToStorage } from '~/utils/storage' import { loadFromStorage, saveToStorage, removeFromStorage } from '~/utils/storage'
export const SAFES_KEY = 'SAFES' export const SAFES_KEY = 'SAFES'
export const TX_KEY = 'TX' export const TX_KEY = 'TX'
@ -41,3 +41,12 @@ export const getOwners = async (safeAddress: string): Map<string, string> => {
console.log(data) console.log(data)
return data ? Map(data) : Map() return data ? Map(data) : Map()
} }
export const removeOwners = async (safeAddress: string): Map<string, string> => {
try {
await removeFromStorage(`${OWNERS_KEY}-${safeAddress}`)
} catch (err) {
// eslint-disable-next-line
console.log('Error removing owners from localstorage')
}
}

View File

@ -8,9 +8,6 @@ type AddTokenProps = {
token: Token, token: Token,
} }
export const addToken = createAction<string, *, *>( export const addToken = createAction<string, *, *>(ADD_TOKEN, (token: Token): AddTokenProps => ({
ADD_TOKEN, token,
(token: Token): AddTokenProps => ({ }))
token,
}),
)

View File

@ -9,11 +9,8 @@ type TokenProps = {
tokens: Map<string, Token>, tokens: Map<string, Token>,
} }
const addTokens = createAction<string, *, *>( const addTokens = createAction<string, *, *>(ADD_TOKENS, (tokens: Map<string, Token>): TokenProps => ({
ADD_TOKENS, tokens,
(tokens: Map<string, Token>): TokenProps => ({ }))
tokens,
}),
)
export default addTokens export default addTokens

View File

@ -7,12 +7,13 @@ import Heading from '~/components/layout/Heading'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import ReviewInformation from '~/routes/load/components/ReviewInformation' import ReviewInformation from '~/routes/load/components/ReviewInformation'
import OwnerList from '~/routes/load/components/OwnerList'
import DetailsForm, { safeFieldsValidation } from '~/routes/load/components/DetailsForm' import DetailsForm, { safeFieldsValidation } from '~/routes/load/components/DetailsForm'
import { history } from '~/store' import { history } from '~/store'
import { secondary } from '~/theme/variables' import { secondary } from '~/theme/variables'
import { type SelectorProps } from '~/routes/load/container/selector' import { type SelectorProps } from '~/routes/load/container/selector'
const getSteps = () => ['Details', 'Review'] const getSteps = () => ['Details', 'Owners', 'Review']
type Props = SelectorProps & { type Props = SelectorProps & {
onLoadSafeSubmit: (values: Object) => Promise<void>, onLoadSafeSubmit: (values: Object) => Promise<void>,
@ -32,6 +33,7 @@ const Layout = ({
provider, onLoadSafeSubmit, network, userAddress, provider, onLoadSafeSubmit, network, userAddress,
}: Props) => { }: Props) => {
const steps = getSteps() const steps = getSteps()
const initialValues = {}
return ( return (
<React.Fragment> <React.Fragment>
@ -43,15 +45,16 @@ const Layout = ({
</IconButton> </IconButton>
<Heading tag="h2">Load existing Safe</Heading> <Heading tag="h2">Load existing Safe</Heading>
</Row> </Row>
<Stepper onSubmit={onLoadSafeSubmit} steps={steps} testId="load-safe-form"> <Stepper onSubmit={onLoadSafeSubmit} steps={steps} initialValues={initialValues} testId="load-safe-form">
<Stepper.Page validate={safeFieldsValidation}>{DetailsForm}</Stepper.Page> <Stepper.Page validate={safeFieldsValidation}>{DetailsForm}</Stepper.Page>
<Stepper.Page network={network}>{OwnerList}</Stepper.Page>
<Stepper.Page network={network} userAddress={userAddress}> <Stepper.Page network={network} userAddress={userAddress}>
{ReviewInformation} {ReviewInformation}
</Stepper.Page> </Stepper.Page>
</Stepper> </Stepper>
</Block> </Block>
) : ( ) : (
<div>No metamask detected</div> <div>No account detected</div>
)} )}
</React.Fragment> </React.Fragment>
) )

View File

@ -0,0 +1,191 @@
// @flow
import * as React from 'react'
import Block from '~/components/layout/Block'
import { withStyles } from '@material-ui/core/styles'
import Field from '~/components/forms/Field'
import { required } from '~/components/forms/validator'
import TextField from '~/components/forms/TextField'
import OpenInNew from '@material-ui/icons/OpenInNew'
import Identicon from '~/components/Identicon'
import OpenPaper from '~/components/Stepper/OpenPaper'
import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row'
import Link from '~/components/layout/Link'
import Paragraph from '~/components/layout/Paragraph'
import Hairline from '~/components/layout/Hairline'
import {
sm, md, lg, border, secondary,
} from '~/theme/variables'
import { getOwnerNameBy, getOwnerAddressBy } from '~/routes/open/components/fields'
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { FIELD_LOAD_ADDRESS, THRESHOLD } from '~/routes/load/components/fields'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
const openIconStyle = {
height: '16px',
color: secondary,
}
const styles = () => ({
details: {
padding: lg,
borderRight: `solid 1px ${border}`,
height: '100%',
},
owners: {
display: 'flex',
justifyContent: 'flex-start',
},
ownerNames: {
maxWidth: '400px',
},
ownerAddresses: {
alignItems: 'center',
marginLeft: `${sm}`,
},
address: {
paddingLeft: '6px',
},
open: {
paddingLeft: sm,
width: 'auto',
'&:hover': {
cursor: 'pointer',
},
},
title: {
padding: `${md} ${lg}`,
},
owner: {
padding: `0 ${lg}`,
marginBottom: '12px',
},
header: {
padding: `${sm} ${lg}`,
},
name: {
marginRight: `${sm}`,
},
})
type LayoutProps = {
network: string,
}
type Props = LayoutProps & {
values: Object,
classes: Object,
updateInitialProps: (initialValues: Object) => void,
}
type State = {
owners: Array<string>,
}
const calculateSafeValues = (owners: Array<string>, threshold: Number, values: Object) => {
const initialValues = { ...values }
for (let i = 0; i < owners.length; i += 1) {
initialValues[getOwnerAddressBy(i)] = owners[i]
}
initialValues[THRESHOLD] = threshold
return initialValues
}
class OwnerListComponent extends React.PureComponent<Props, State> {
state = {
owners: [],
}
mounted = false
componentDidMount = async () => {
this.mounted = true
const { values, updateInitialProps } = this.props
const safeAddress = values[FIELD_LOAD_ADDRESS]
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const owners = await gnosisSafe.getOwners()
const threshold = await gnosisSafe.getThreshold()
const initialValues = calculateSafeValues(owners.sort(), threshold, values)
updateInitialProps(initialValues)
if (!owners) {
return
}
if (this.mounted) {
this.setState(() => ({ owners: owners.sort() }))
}
}
componentWillUnmount() {
this.mounted = false
}
render() {
const { network, classes } = this.props
const { owners } = this.state
return (
<React.Fragment>
<Block className={classes.title}>
<Paragraph noMargin size="md" color="primary">
{`This Safe has ${owners.length} owners. Optional: Provide a name for each owner.`}
</Paragraph>
</Block>
<Hairline />
<Row className={classes.header}>
<Col xs={4}>NAME</Col>
<Col xs={8}>ADDRESS</Col>
</Row>
<Hairline />
<Block margin="md" padding="md">
{owners.map((x, index) => (
<Row key={owners[index].address} className={classes.owner}>
<Col xs={4}>
<Field
className={classes.name}
name={getOwnerNameBy(index)}
component={TextField}
type="text"
validate={required}
defaultValue={`Owner #${index + 1}`}
placeholder="Owner Name*"
text="Owner Name"
/>
</Col>
<Col xs={7}>
<Row className={classes.ownerAddresses}>
<Identicon address={owners[index]} diameter={32} />
<Paragraph size="md" color="disabled" noMargin className={classes.address}>
{owners[index]}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(owners[index], network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Row>
</Col>
</Row>
)) }
</Block>
</React.Fragment>
)
}
}
const OwnerListPage = withStyles(styles)(OwnerListComponent)
const OwnerList = ({ updateInitialProps }: Object, network: string) => (controls: React$Node, { values }: Object) => (
<React.Fragment>
<OpenPaper controls={controls} padding={false}>
<OwnerListPage
network={network}
updateInitialProps={updateInitialProps}
values={values}
/>
</OpenPaper>
</React.Fragment>
)
export default OwnerList

View File

@ -1,20 +1,24 @@
// @flow // @flow
import * as React from 'react' import * as React from 'react'
import classNames from 'classnames'
import Block from '~/components/layout/Block' import Block from '~/components/layout/Block'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import OpenInNew from '@material-ui/icons/OpenInNew' import OpenInNew from '@material-ui/icons/OpenInNew'
import Identicon from '~/components/Identicon' import Identicon from '~/components/Identicon'
import OpenPaper from '~/components/Stepper/OpenPaper' import OpenPaper from '~/components/Stepper/OpenPaper'
import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import Link from '~/components/layout/Link' import Link from '~/components/layout/Link'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import Hairline from '~/components/layout/Hairline'
import { import {
xs, sm, lg, border, secondary, xs, sm, lg, border, secondary,
} from '~/theme/variables' } from '~/theme/variables'
import { getEtherScanLink, getWeb3 } from '~/logic/wallets/getWeb3' import { shortVersionOf } from '~/logic/wallets/ethAddresses'
import { FIELD_LOAD_NAME, FIELD_LOAD_ADDRESS } from '~/routes/load/components/fields' import { getAccountsFrom } from '~/routes/open/utils/safeDataExtractor'
import { sameAddress } from '~/logic/wallets/ethAddresses' import { getOwnerNameBy, getOwnerAddressBy, getNumOwnersFrom } from '~/routes/open/components/fields'
import { getGnosisSafeContract } from '~/logic/contracts/safeContracts' import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { FIELD_LOAD_NAME, FIELD_LOAD_ADDRESS, THRESHOLD } from '~/routes/load/components/fields'
const openIconStyle = { const openIconStyle = {
height: '16px', height: '16px',
@ -22,20 +26,31 @@ const openIconStyle = {
} }
const styles = () => ({ const styles = () => ({
root: {
minHeight: '300px',
},
details: { details: {
padding: lg, padding: lg,
borderRight: `solid 1px ${border}`, borderRight: `solid 1px ${border}`,
height: '100%', height: '100%',
}, },
name: { owners: {
letterSpacing: '-0.6px', padding: lg,
}, },
container: { name: {
marginTop: xs, textOverflow: 'ellipsis',
overflow: 'hidden',
},
userName: {
whiteSpace: 'nowrap',
},
owner: {
padding: sm,
paddingLeft: lg,
alignItems: 'center', alignItems: 'center',
}, },
address: { user: {
paddingLeft: '6px', justifyContent: 'left',
}, },
open: { open: {
paddingLeft: sm, paddingLeft: sm,
@ -44,6 +59,13 @@ const styles = () => ({
cursor: 'pointer', cursor: 'pointer',
}, },
}, },
container: {
marginTop: xs,
alignItems: 'center',
},
address: {
paddingLeft: '6px',
},
}) })
type LayoutProps = { type LayoutProps = {
@ -60,77 +82,113 @@ type State = {
isOwner: boolean, isOwner: boolean,
} }
const checkUserAddressOwner = (values: Object, userAddress: string): boolean => {
let isOwner: boolean = false
for (let i = 0; i < getNumOwnersFrom(values); i += 1) {
if (values[getOwnerAddressBy(i)] === userAddress) {
isOwner = true
break
}
}
return isOwner
}
class ReviewComponent extends React.PureComponent<Props, State> { class ReviewComponent extends React.PureComponent<Props, State> {
state = {
isOwner: false,
}
mounted = false
componentDidMount = async () => {
this.mounted = true
const { values, userAddress } = this.props
const safeAddress = values[FIELD_LOAD_ADDRESS]
const web3 = getWeb3()
const GnosisSafe = getGnosisSafeContract(web3)
const gnosisSafe = await GnosisSafe.at(safeAddress)
const owners = await gnosisSafe.getOwners()
if (!owners) {
return
}
const isOwner = owners.find((owner: string) => sameAddress(owner, userAddress)) !== undefined
if (this.mounted) {
this.setState(() => ({ isOwner }))
}
}
componentWillUnmount() {
this.mounted = false
}
render() { render() {
const { values, classes, network } = this.props const {
const { isOwner } = this.state values, classes, network, userAddress,
} = this.props
const isOwner = checkUserAddressOwner(values, userAddress)
const owners = getAccountsFrom(values)
const safeAddress = values[FIELD_LOAD_ADDRESS] const safeAddress = values[FIELD_LOAD_ADDRESS]
return ( return (
<React.Fragment> <React.Fragment>
<Block className={classes.details}> <Row className={classes.root}>
<Block margin="lg"> <Col xs={4} layout="column">
<Paragraph size="sm" color="disabled" noMargin> <Block className={classes.details}>
Name of the Safe <Block margin="lg">
</Paragraph> <Paragraph size="lg" color="primary" noMargin>
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}> Review details
{values[FIELD_LOAD_NAME]} </Paragraph>
</Paragraph> </Block>
</Block> <Block margin="lg">
<Block margin="lg"> <Paragraph size="sm" color="disabled" noMargin>
<Paragraph size="sm" color="disabled" noMargin> Name of the Safe
Safe address </Paragraph>
</Paragraph> <Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
<Row className={classes.container}> {values[FIELD_LOAD_NAME]}
<Identicon address={safeAddress} diameter={32} /> </Paragraph>
<Paragraph size="md" color="disabled" noMargin className={classes.address}> </Block>
{safeAddress} <Block margin="lg">
<Paragraph size="sm" color="disabled" noMargin>
Safe address
</Paragraph>
<Row className={classes.container}>
<Identicon address={safeAddress} diameter={32} />
<Paragraph size="md" color="disabled" noMargin className={classes.address}>
{shortVersionOf(safeAddress, 4)}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(safeAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Row>
</Block>
<Block margin="lg">
<Paragraph size="sm" color="disabled" noMargin>
Connected wallet client is owner?
</Paragraph>
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
{isOwner ? 'Yes' : 'No (read-only)'}
</Paragraph>
</Block>
<Block margin="lg">
<Paragraph size="sm" color="disabled" noMargin>
Any transaction requires the confirmation of:
</Paragraph>
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}>
{`${values[THRESHOLD]} out of ${getNumOwnersFrom(values)} owners`}
</Paragraph>
</Block>
</Block>
</Col>
<Col xs={8} layout="column">
<Block className={classes.owners}>
<Paragraph size="lg" color="primary" noMargin>
{`${getNumOwnersFrom(values)} Safe owners`}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(safeAddress, network)} target="_blank"> </Block>
<OpenInNew style={openIconStyle} /> <Hairline />
</Link> {owners.map((x, index) => (
</Row> <React.Fragment key={owners[index].address}>
</Block> <Row className={classes.owner}>
<Block margin="lg"> <Col xs={1} align="center">
<Paragraph size="sm" color="disabled" noMargin> <Identicon address={owners[index]} diameter={32} />
Connected wallet client is owner? </Col>
</Paragraph> <Col xs={11}>
<Paragraph size="lg" color="primary" noMargin weight="bolder" className={classes.name}> <Block className={classNames(classes.name, classes.userName)}>
{isOwner ? 'Yes' : 'No (read-only)'} <Paragraph size="lg" noMargin>
</Paragraph> {values[getOwnerNameBy(index)]}
</Block> </Paragraph>
</Block> <Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{owners[index]}
</Paragraph>
<Link className={classes.open} to={getEtherScanLink(owners[index], network)} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
<Hairline />
</React.Fragment>
))}
</Col>
</Row>
</React.Fragment> </React.Fragment>
) )
} }

View File

@ -1,3 +1,4 @@
// @flow // @flow
export const FIELD_LOAD_NAME: string = 'name' export const FIELD_LOAD_NAME: string = 'name'
export const FIELD_LOAD_ADDRESS: string = 'address' export const FIELD_LOAD_ADDRESS: string = 'address'
export const THRESHOLD: Number = 'threshold'

View File

@ -10,13 +10,20 @@ import { history } from '~/store'
import selector, { type SelectorProps } from './selector' import selector, { type SelectorProps } from './selector'
import actions, { type Actions } from './actions' import actions, { type Actions } from './actions'
import Layout from '../components/Layout' import Layout from '../components/Layout'
import { getNamesFrom, getOwnersFrom } from '~/routes/open/utils/safeDataExtractor'
import { FIELD_LOAD_NAME, FIELD_LOAD_ADDRESS } from '../components/fields' import { FIELD_LOAD_NAME, FIELD_LOAD_ADDRESS } from '../components/fields'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
type Props = SelectorProps & Actions type Props = SelectorProps & Actions
export const loadSafe = async (safeName: string, safeAddress: string, addSafe: Function) => { export const loadSafe = async (
safeName: string,
safeAddress: string,
owners: Array,
addSafe: Function
) => {
const safeProps = await buildSafe(safeAddress, safeName) const safeProps = await buildSafe(safeAddress, safeName)
safeProps.owners = owners
await addSafe(safeProps) await addSafe(safeProps)
const storedSafes = (await loadFromStorage(SAFES_KEY)) || {} const storedSafes = (await loadFromStorage(SAFES_KEY)) || {}
@ -31,8 +38,13 @@ class Load extends React.Component<Props> {
const { addSafe } = this.props const { addSafe } = this.props
const safeName = values[FIELD_LOAD_NAME] const safeName = values[FIELD_LOAD_NAME]
const safeAddress = values[FIELD_LOAD_ADDRESS] const safeAddress = values[FIELD_LOAD_ADDRESS]
const ownerNames = getNamesFrom(values)
await loadSafe(safeName, safeAddress, addSafe) const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const ownerAddresses = await gnosisSafe.getOwners()
const owners = getOwnersFrom(ownerNames, ownerAddresses.sort())
await loadSafe(safeName, safeAddress, owners, addSafe)
const url = `${SAFELIST_ADDRESS}/${safeAddress}` const url = `${SAFELIST_ADDRESS}/${safeAddress}`
history.push(url) history.push(url)

View File

@ -1,4 +1,6 @@
// @flow // @flow
import { makeOwner } from '~/routes/safe/store/models/owner'
export const getAccountsFrom = (values: Object): string[] => { export const getAccountsFrom = (values: Object): string[] => {
const accounts = Object.keys(values) const accounts = Object.keys(values)
.sort() .sort()
@ -15,6 +17,12 @@ export const getNamesFrom = (values: Object): string[] => {
return accounts.map(account => values[account]).slice(0, values.owners) return accounts.map(account => values[account]).slice(0, values.owners)
} }
export const getOwnersFrom = (names: string[], addresses: string[]): Array<string, string> => {
const owners = names.map((name: string, index: number) => makeOwner({ name, address: addresses[index] }))
return owners
}
export const getThresholdFrom = (values: Object): number => Number(values.confirmations) export const getThresholdFrom = (values: Object): number => Number(values.confirmations)
export const getSafeNameFrom = (values: Object): string => values.name export const getSafeNameFrom = (values: Object): string => values.name

View File

@ -17,8 +17,12 @@ import { copyToClipboard } from '~/utils/clipboard'
import Hairline from '~/components/layout/Hairline' import Hairline from '~/components/layout/Hairline'
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo' import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils' import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import ArrowDown from '../assets/arrow-down.svg' import ArrowDown from '../assets/arrow-down.svg'
import { secondary } from '~/theme/variables' import { secondary } from '~/theme/variables'
import { isEther } from '~/logic/tokens/utils/tokenHelpers'
import { styles } from './style' import { styles } from './style'
type Props = { type Props = {
@ -50,87 +54,109 @@ const ReviewTx = ({
createTransaction, createTransaction,
}: Props) => ( }: Props) => (
<SharedSnackbarConsumer> <SharedSnackbarConsumer>
{({ openSnackbar }) => ( {({ openSnackbar }) => {
<React.Fragment> const submitTx = async () => {
<Row align="center" grow className={classes.heading}> const web3 = getWeb3()
<Paragraph weight="bolder" className={classes.headingText} noMargin> const isSendingETH = isEther(tx.token.symbol)
Send Funds const txRecipient = isSendingETH ? tx.recipientAddress : tx.token.address
</Paragraph> let txData = EMPTY_DATA
<Paragraph className={classes.annotation}>2 of 2</Paragraph> let txAmount = web3.utils.toWei(tx.amount, 'ether')
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton> if (!isSendingETH) {
</Row> const StandardToken = await getStandardTokenContract()
<Hairline /> const tokenInstance = await StandardToken.at(tx.token.address)
<Block className={classes.container}>
<SafeInfo txData = tokenInstance.contract.methods.transfer(tx.recipientAddress, txAmount).encodeABI()
safeAddress={safeAddress} // txAmount should be 0 if we send tokens
etherScanLink={etherScanLink} // the real value is encoded in txData and will be used by the contract
safeName={safeName} // if txAmount > 0 it would send ETH from the safe
ethBalance={ethBalance} txAmount = 0
/> }
<Row margin="md">
<Col xs={1}> createTransaction(safeAddress, txRecipient, txAmount, txData, openSnackbar)
<img src={ArrowDown} alt="Arrow Down" style={{ marginLeft: '8px' }} /> onClose()
</Col> }
<Col xs={11} center="xs" layout="column">
<Hairline /> return (
</Col> <React.Fragment>
</Row> <Row align="center" grow className={classes.heading}>
<Row margin="xs"> <Paragraph weight="bolder" className={classes.headingText} noMargin>
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin> Send Funds
Recipient
</Paragraph> </Paragraph>
<Paragraph className={classes.annotation}>2 of 2</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row> </Row>
<Row margin="md" align="center"> <Hairline />
<Col xs={1}> <Block className={classes.container}>
<Identicon address={tx.recipientAddress} diameter={32} /> <SafeInfo
</Col> safeAddress={safeAddress}
<Col xs={11} layout="column"> etherScanLink={etherScanLink}
<Paragraph weight="bolder" onClick={copyToClipboard} noMargin> safeName={safeName}
{tx.recipientAddress} ethBalance={ethBalance}
<Link to={etherScanLink} target="_blank"> />
<OpenInNew style={openIconStyle} /> <Row margin="md">
</Link> <Col xs={1}>
<img src={ArrowDown} alt="Arrow Down" style={{ marginLeft: '8px' }} />
</Col>
<Col xs={11} center="xs" layout="column">
<Hairline />
</Col>
</Row>
<Row margin="xs">
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin>
Recipient
</Paragraph> </Paragraph>
</Col> </Row>
<Row margin="md" align="center">
<Col xs={1}>
<Identicon address={tx.recipientAddress} diameter={32} />
</Col>
<Col xs={11} layout="column">
<Paragraph weight="bolder" onClick={copyToClipboard} noMargin>
{tx.recipientAddress}
<Link to={etherScanLink} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Paragraph>
</Col>
</Row>
<Row margin="xs">
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin>
Amount
</Paragraph>
</Row>
<Row margin="md" align="center">
<Img src={tx.token.logoUri} height={28} alt={tx.token.name} onError={setImageToPlaceholder} />
<Paragraph size="md" noMargin className={classes.amount}>
{tx.amount}
{' '}
{tx.token.symbol}
</Paragraph>
</Row>
</Block>
<Hairline style={{ position: 'absolute', bottom: 85 }} />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClickBack}>
Back
</Button>
<Button
type="submit"
className={classes.button}
onClick={submitTx}
variant="contained"
minWidth={140}
color="primary"
data-testid="submit-tx-btn"
>
SUBMIT
</Button>
</Row> </Row>
<Row margin="xs"> </React.Fragment>
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin> )
Amount }}
</Paragraph>
</Row>
<Row margin="md" align="center">
<Img src={tx.token.logoUri} height={28} alt={tx.token.name} onError={setImageToPlaceholder} />
<Paragraph size="md" noMargin className={classes.amount}>
{tx.amount}
{' '}
{tx.token.symbol}
</Paragraph>
</Row>
</Block>
<Hairline style={{ position: 'absolute', bottom: 85 }} />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClickBack}>
Back
</Button>
<Button
type="submit"
className={classes.button}
onClick={() => {
createTransaction(safeAddress, tx.recipientAddress, tx.amount, tx.token, openSnackbar)
onClose()
}}
variant="contained"
minWidth={140}
color="primary"
data-testid="submit-tx-btn"
>
SUBMIT
</Button>
</Row>
</React.Fragment>
)}
</SharedSnackbarConsumer> </SharedSnackbarConsumer>
) )

View File

@ -20,6 +20,7 @@ import {
import { copyToClipboard } from '~/utils/clipboard' import { copyToClipboard } from '~/utils/clipboard'
import Balances from './Balances' import Balances from './Balances'
import Transactions from './TransactionsNew' import Transactions from './TransactionsNew'
import Settings from './Settings'
type Props = SelectorProps & { type Props = SelectorProps & {
classes: Object, classes: Object,
@ -155,6 +156,17 @@ class Layout extends React.Component<Props, State> {
{tabIndex === 1 && ( {tabIndex === 1 && (
<Transactions transactions={transactions} fetchTransactions={fetchTransactions} safeAddress={address} /> <Transactions transactions={transactions} fetchTransactions={fetchTransactions} safeAddress={address} />
)} )}
{tabIndex === 2 && (
<Settings
granted={granted}
safeAddress={address}
safeName={name}
etherScanLink={etherScanLink}
threshold={safe.threshold}
owners={safe.owners}
createTransaction={createTransaction}
/>
)}
</React.Fragment> </React.Fragment>
) )
} }

View File

@ -0,0 +1,10 @@
// @flow
import removeSafe from '~/routes/safe/store/actions/removeSafe'
export type Actions = {
removeSafe: Function,
}
export default {
removeSafe,
}

View File

@ -0,0 +1,119 @@
// @flow
import React from 'react'
import classNames from 'classnames'
import { connect } from 'react-redux'
import { history } from '~/store'
import { SAFELIST_ADDRESS } from '~/routes/routes'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import OpenInNew from '@material-ui/icons/OpenInNew'
import Block from '~/components/layout/Block'
import Modal from '~/components/Modal'
import Identicon from '~/components/Identicon'
import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row'
import Button from '~/components/layout/Button'
import Link from '~/components/layout/Link'
import Paragraph from '~/components/layout/Paragraph'
import Hairline from '~/components/layout/Hairline'
import actions, { type Actions } from './actions'
import { lg, md, secondary } from '~/theme/variables'
import { styles } from './style'
const openIconStyle = {
height: '16px',
color: secondary,
}
type Props = Actions & {
onClose: () => void,
classes: Object,
isOpen: boolean,
safeAddress: string,
etherScanLink: string,
safeName: string,
}
const RemoveSafeComponent = ({
onClose,
isOpen,
classes,
safeAddress,
etherScanLink,
safeName,
removeSafe,
}: Props) => (
<Modal
title="Remove Safe"
description="Remove the selected Safe"
handleClose={onClose}
open={isOpen}
>
<Row align="center" grow className={classes.heading}>
<Paragraph className={classes.manage} noMargin>
Remove Safe
</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.close} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.container}>
<Row className={classes.owner}>
<Col xs={1} align="center">
<Identicon address={safeAddress} diameter={32} />
</Col>
<Col xs={11}>
<Block className={classNames(classes.name, classes.userName)}>
<Paragraph size="lg" noMargin>
{safeName}
</Paragraph>
<Block align="center" className={classes.user}>
<Paragraph size="md" color="disabled" noMargin>
{safeAddress}
</Paragraph>
<Link className={classes.open} to={etherScanLink} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Block>
</Block>
</Col>
</Row>
<Hairline />
<Row className={classes.description}>
<Paragraph noMargin>
Removing a Safe only removes it from your interface.
<b>It does not delete the Safe</b>.
You can always add it back using the Safe's address.
</Paragraph>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
className={classes.buttonRemove}
onClick={() => {
removeSafe(safeAddress)
onClose()
history.push(SAFELIST_ADDRESS)
}}
variant="contained"
minWidth={140}
>
Remove
</Button>
</Row>
</Modal>
)
const RemoveSafeModal = withStyles(styles)(RemoveSafeComponent)
export default connect(
undefined,
actions,
)(RemoveSafeModal)

View File

@ -0,0 +1,56 @@
// @flow
import {
lg, md, sm, error, background,
} from '~/theme/variables'
export const styles = (theme: Object) => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'space-between',
maxHeight: '75px',
boxSizing: 'border-box',
},
container: {
minHeight: '369px',
},
manage: {
fontSize: '24px',
},
close: {
height: '35px',
width: '35px',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
buttonRemove: {
color: '#fff',
backgroundColor: error,
},
name: {
textOverflow: 'ellipsis',
overflow: 'hidden',
},
userName: {
whiteSpace: 'nowrap',
},
owner: {
backgroundColor: background,
padding: md,
alignItems: 'center',
},
user: {
justifyContent: 'left',
},
description: {
padding: md,
},
open: {
paddingLeft: sm,
width: 'auto',
'&:hover': {
cursor: 'pointer',
},
},
})

View File

@ -0,0 +1,120 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import SelectField from '~/components/forms/SelectField'
import MenuItem from '@material-ui/core/MenuItem'
import {
composeValidators, minValue, mustBeInteger, required, differentFrom,
} from '~/components/forms/validator'
import Field from '~/components/forms/Field'
import GnoForm from '~/components/forms/GnoForm'
import Hairline from '~/components/layout/Hairline'
import Paragraph from '~/components/layout/Paragraph'
import Button from '~/components/layout/Button'
import Block from '~/components/layout/Block'
import Row from '~/components/layout/Row'
import Col from '~/components/layout/Col'
import type { Owner } from '~/routes/safe/store/models/owner'
import { styles } from './style'
type Props = {
onClose: () => void,
classes: Object,
threshold: number,
owners: List<Owner>,
onChangeThreshold: Function,
}
const THRESHOLD_FIELD_NAME = 'threshold'
const ChangeThreshold = ({
onClose, owners, threshold, classes, onChangeThreshold,
}: Props) => {
const handleSubmit = async (values) => {
const newThreshold = values[THRESHOLD_FIELD_NAME]
await onChangeThreshold(newThreshold)
onClose()
}
return (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph className={classes.headingText} weight="bolder" noMargin>
Change required confirmations
</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.close} />
</IconButton>
</Row>
<Hairline />
<GnoForm onSubmit={handleSubmit} initialValues={{ threshold: threshold.toString() }}>
{() => (
<React.Fragment>
<Block className={classes.modalContent}>
<Row>
<Paragraph>
Every transaction outside any specified daily limits, needs to be confirmed by all specified owners.
If no daily limits are set, all owners will need to sign for transactions.
</Paragraph>
</Row>
<Row>
<Paragraph weight="bolder">
Any transaction over any daily limit requires the confirmation of:
</Paragraph>
</Row>
<Row margin="xl" align="center" className={classes.inputRow}>
<Col xs={2}>
<Field
name={THRESHOLD_FIELD_NAME}
render={props => (
<>
<SelectField {...props} disableError>
{[...Array(Number(owners.size))].map((x, index) => (
<MenuItem key={index} value={`${index + 1}`}>
{index + 1}
</MenuItem>
))}
</SelectField>
{props.meta.error && props.meta.touched && (
<Paragraph className={classes.errorText} noMargin color="error">
{props.meta.error}
</Paragraph>
)}
</>
)}
validate={composeValidators(required, mustBeInteger, minValue(1), differentFrom(threshold))}
data-testid="threshold-select-input"
/>
</Col>
<Col xs={10}>
<Paragraph size="lg" color="primary" noMargin className={classes.ownersText}>
out of
{' '}
{owners.size}
{' '}
owner(s)
</Paragraph>
</Col>
</Row>
</Block>
<Hairline style={{ position: 'absolute', bottom: 85 }} />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClose}>
BACK
</Button>
<Button type="submit" color="primary" className={classes.button} minWidth={140} variant="contained">
CHANGE
</Button>
</Row>
</React.Fragment>
)}
</GnoForm>
</React.Fragment>
)
}
export default withStyles(styles)(ChangeThreshold)

View File

@ -0,0 +1,44 @@
// @flow
import { lg, md, sm } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'space-between',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
headingText: {
fontSize: '20px',
},
close: {
height: '35px',
width: '35px',
},
modalContent: {
padding: `${md} ${lg}`,
},
ownersText: {
marginLeft: sm,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
position: 'absolute',
bottom: 0,
width: '100%',
},
inputRow: {
position: 'relative',
},
errorText: {
position: 'absolute',
bottom: '-25px',
},
})

View File

@ -0,0 +1,101 @@
// @flow
import React, { useState } from 'react'
import { withStyles } from '@material-ui/core/styles'
import { List } from 'immutable'
import { SharedSnackbarConsumer } from '~/components/SharedSnackBar'
import Heading from '~/components/layout/Heading'
import Button from '~/components/layout/Button'
import Bold from '~/components/layout/Bold'
import Block from '~/components/layout/Block'
import Row from '~/components/layout/Row'
import Modal from '~/components/Modal'
import Paragraph from '~/components/layout/Paragraph'
import ChangeThreshold from './ChangeThreshold'
import type { Owner } from '~/routes/safe/store/models/owner'
import { styles } from './style'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { ZERO_ADDRESS } from '~/logic/wallets/ethAddresses'
type Props = {
owners: List<Owner>,
threshold: number,
classes: Object,
createTransaction: Function,
safeAddress: string,
}
const ThresholdSettings = ({
owners, threshold, classes, createTransaction, safeAddress,
}: Props) => {
const [isModalOpen, setModalOpen] = useState(false)
const toggleModal = () => {
setModalOpen(prevOpen => !prevOpen)
}
return (
<React.Fragment>
<SharedSnackbarConsumer>
{({ openSnackbar }) => {
const onChangeThreshold = async (newThreshold) => {
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const txData = safeInstance.contract.methods.changeThreshold(newThreshold).encodeABI()
createTransaction(safeAddress, safeAddress, 0, txData, openSnackbar)
}
return (
<>
<Block className={classes.container}>
<Heading tag="h3">Required confirmations</Heading>
<Paragraph>
Any transaction over any daily limit
<br />
{' '}
requires the confirmation of:
</Paragraph>
<Paragraph size="xxl" className={classes.ownersText}>
<Bold>{threshold}</Bold>
{' '}
out of
{' '}
<Bold>{owners.size}</Bold>
{' '}
owners
</Paragraph>
{owners.size > 1 && (
<Row align="center" className={classes.buttonRow}>
<Button
color="primary"
minWidth={120}
className={classes.modifyBtn}
onClick={toggleModal}
variant="contained"
>
Modify
</Button>
</Row>
)}
</Block>
<Modal
title="Change Required Confirmations"
description="Change Required Confirmations Form"
handleClose={toggleModal}
open={isModalOpen}
>
<ChangeThreshold
onClose={toggleModal}
owners={owners}
threshold={threshold}
onChangeThreshold={onChangeThreshold}
/>
</Modal>
</>
)
}}
</SharedSnackbarConsumer>
</React.Fragment>
)
}
export default withStyles(styles)(ThresholdSettings)

View File

@ -0,0 +1,35 @@
// @flow
import {
fontColor, lg, smallFontSize, md,
} from '~/theme/variables'
export const styles = () => ({
ownersText: {
fontSize: '26px',
color: '#8896b6',
'& b': {
color: fontColor,
},
},
container: {
height: '100%',
position: 'relative',
padding: lg,
},
buttonRow: {
position: 'absolute',
bottom: '51px',
left: 0,
height: '51px',
width: '100%',
paddingRight: md,
display: 'flex',
justifyContent: 'flex-end',
borderTop: 'solid 1px #e4e8f1',
boxSizing: 'border-box',
},
modifyBtn: {
height: '32px',
fontSize: smallFontSize,
},
})

View File

@ -0,0 +1,56 @@
// @flow
import * as React from 'react'
import { withStyles } from '@material-ui/core/styles'
import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row'
import Paragraph from '~/components/layout/Paragraph'
import Button from '~/components/layout/Button'
import { sm, boldFont } from '~/theme/variables'
import { styles } from './style'
const controlsStyle = {
backgroundColor: 'white',
padding: sm,
}
const saveButtonStyle = {
marginRight: sm,
fontWeight: boldFont,
}
type Props = {
classes: Object,
}
class UpdateSafeName extends React.Component<Props, State> {
render() {
const { classes } = this.props
return (
<React.Fragment>
<Block margin="lg">
<Paragraph size="lg" color="primary" noMargin>
Details
</Paragraph>
</Block>
<Row style={controlsStyle} align="end" grow>
<Col end="xs">
<Button
type="submit"
style={saveButtonStyle}
size="small"
variant="contained"
color="primary"
onClick={() => {}}
>
SAVE
</Button>
</Col>
</Row>
</React.Fragment>
)
}
}
export default withStyles(styles)(UpdateSafeName)

View File

@ -0,0 +1,4 @@
// @flow
import { lg, border } from '~/theme/variables'
export const styles = () => ({})

View File

@ -0,0 +1,135 @@
// @flow
import * as React from 'react'
import cn from 'classnames'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row'
import RemoveSafeModal from './RemoveSafeModal'
import Paragraph from '~/components/layout/Paragraph'
import Hairline from '~/components/layout/Hairline'
import type { Owner } from '~/routes/safe/store/models/owner'
import ThresholdSettings from './ThresholdSettings'
import { styles } from './style'
type State = {
showRemoveSafe: boolean,
menuOptionIndex: number,
}
type Props = {
classes: Object,
granted: boolean,
etherScanLink: string,
safeAddress: string,
safeName: string,
owners: List<Owner>,
threshold: number,
createTransaction: Function,
}
type Action = 'RemoveSafe'
class Settings extends React.Component<Props, State> {
state = {
showRemoveSafe: false,
menuOptionIndex: 1,
}
handleChange = menuOptionIndex => () => {
this.setState({ menuOptionIndex })
}
onShow = (action: Action) => () => {
this.setState(() => ({ [`show${action}`]: true }))
}
onHide = (action: Action) => () => {
this.setState(() => ({ [`show${action}`]: false }))
}
render() {
const { showRemoveSafe, menuOptionIndex } = this.state
const {
classes, granted, etherScanLink, safeAddress, safeName, owners, threshold, createTransaction,
} = this.props
return (
<React.Fragment>
<Row align="center" className={classes.message}>
<Col xs={6}>
<Paragraph className={classes.settings}>Settings</Paragraph>
</Col>
<Col xs={6} end="sm">
<Paragraph noMargin size="md" color="error" className={classes.links} onClick={this.onShow('RemoveSafe')}>
Remove Safe
</Paragraph>
<RemoveSafeModal
onClose={this.onHide('RemoveSafe')}
isOpen={showRemoveSafe}
etherScanLink={etherScanLink}
safeAddress={safeAddress}
safeName={safeName}
/>
</Col>
</Row>
<Block className={classes.root}>
<Col xs={3} layout="column">
<Block className={classes.menu}>
<Row
className={cn(classes.menuOption, menuOptionIndex === 1 && classes.active)}
onClick={this.handleChange(1)}
>
Safe name
</Row>
<Hairline />
{granted && (
<React.Fragment>
<Row
className={cn(classes.menuOption, menuOptionIndex === 2 && classes.active)}
onClick={this.handleChange(2)}
>
Owners
</Row>
<Hairline />
<Row
className={cn(classes.menuOption, menuOptionIndex === 3 && classes.active)}
onClick={this.handleChange(3)}
>
Required confirmations
</Row>
<Hairline />
<Row
className={cn(classes.menuOption, menuOptionIndex === 4 && classes.active)}
onClick={this.handleChange(4)}
>
Modules
</Row>
<Hairline />
</React.Fragment>
)}
</Block>
</Col>
<Col xs={9} layout="column">
<Block className={classes.container}>
{menuOptionIndex === 1 && <p>To be done</p>}
{granted && menuOptionIndex === 2 && <p>To be done</p>}
{granted && menuOptionIndex === 3 && (
<ThresholdSettings
owners={owners}
threshold={threshold}
createTransaction={createTransaction}
safeAddress={safeAddress}
/>
)}
{granted && menuOptionIndex === 4 && <p>To be done</p>}
</Block>
</Col>
</Block>
</React.Fragment>
)
}
}
export default withStyles(styles)(Settings)

View File

@ -0,0 +1,42 @@
// @flow
import {
sm, md, lg, border, secondary, bolderFont,
} from '~/theme/variables'
export const styles = (theme: Object) => ({
root: {
backgroundColor: 'white',
boxShadow: '0 -1px 4px 0 rgba(74, 85, 121, 0.5)',
minHeight: '400px',
display: 'flex',
},
settings: {
letterSpacing: '-0.5px',
},
menu: {
borderRight: `solid 1px ${border}`,
height: '100%',
},
menuOption: {
padding: lg,
alignItems: 'center',
cursor: 'pointer',
},
active: {
backgroundColor: '#f4f4f9',
color: secondary,
fontWeight: bolderFont,
},
container: {
height: '100%',
},
message: {
margin: `${sm} 0`,
},
links: {
textDecoration: 'underline',
'&:hover': {
cursor: 'pointer',
},
},
})

View File

@ -11,7 +11,7 @@ export type Props = Actions &
granted: boolean, granted: boolean,
} }
const TIMEOUT = process.env.NODE_ENV === 'test' ? 1500 : 15000 const TIMEOUT = process.env.NODE_ENV === 'test' ? 1500 : 5000
class SafeView extends React.Component<Props> { class SafeView extends React.Component<Props> {
componentDidMount() { componentDidMount() {

View File

@ -18,12 +18,9 @@ type ActionReturn = {
safe: Safe, safe: Safe,
} }
export const addSafe = createAction<string, Function, ActionReturn>( export const addSafe = createAction<string, Function, ActionReturn>(ADD_SAFE, (safe: Safe): ActionReturn => ({
ADD_SAFE, safe,
(safe: Safe): ActionReturn => ({ }))
safe,
}),
)
const saveSafe = (name: string, address: string, threshold: number, ownersName: string[], ownersAddress: string[]) => ( const saveSafe = (name: string, address: string, threshold: number, ownersName: string[], ownersAddress: string[]) => (
dispatch: ReduxDispatch<GlobalState>, dispatch: ReduxDispatch<GlobalState>,

View File

@ -1,52 +1,31 @@
// @flow // @flow
import type { Dispatch as ReduxDispatch, GetState } from 'redux' import type { Dispatch as ReduxDispatch, GetState } from 'redux'
import { createAction } from 'redux-actions' import { createAction } from 'redux-actions'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions' import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { type Token } from '~/logic/tokens/store/model/token'
import { userAccountSelector } from '~/logic/wallets/store/selectors' import { userAccountSelector } from '~/logic/wallets/store/selectors'
import { type GlobalState } from '~/store' import { type GlobalState } from '~/store'
import { isEther } from '~/logic/tokens/utils/tokenHelpers'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { executeTransaction, CALL } from '~/logic/safe/transactions' import { executeTransaction, CALL } from '~/logic/safe/transactions'
import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
const createTransaction = ( const createTransaction = (
safeAddress: string, safeAddress: string,
to: string, to: string,
valueInEth: string, valueInWei: string,
token: Token, txData: string = EMPTY_DATA,
openSnackbar: Function, openSnackbar: Function,
) => async (dispatch: ReduxDispatch<GlobalState>, getState: GetState<GlobalState>) => { ) => async (dispatch: ReduxDispatch<GlobalState>, getState: GetState<GlobalState>) => {
const isSendingETH = isEther(token.symbol)
const state: GlobalState = getState() const state: GlobalState = getState()
const safeInstance = await getGnosisSafeInstanceAt(safeAddress) const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const web3 = getWeb3()
const from = userAccountSelector(state) const from = userAccountSelector(state)
const threshold = await safeInstance.getThreshold() const threshold = await safeInstance.getThreshold()
const nonce = (await safeInstance.nonce()).toString() const nonce = await safeInstance.nonce()
const txRecipient = isSendingETH ? to : token.address
const valueInWei = web3.utils.toWei(valueInEth, 'ether')
let txAmount = valueInWei
const isExecution = threshold.toNumber() === 1 const isExecution = threshold.toNumber() === 1
let txData = EMPTY_DATA
if (!isSendingETH) {
const StandardToken = await getStandardTokenContract()
const sendToken = await StandardToken.at(token.address)
txData = sendToken.contract.methods.transfer(to, valueInWei).encodeABI()
// txAmount should be 0 if we send tokens
// the real value is encoded in txData and will be used by the contract
// if txAmount > 0 it would send ETH from the safe
txAmount = 0
}
let txHash let txHash
if (isExecution) { if (isExecution) {
openSnackbar('Transaction has been submitted', 'success') openSnackbar('Transaction has been submitted', 'success')
txHash = await executeTransaction(safeInstance, txRecipient, txAmount, txData, CALL, nonce, from) txHash = await executeTransaction(safeInstance, to, valueInWei, txData, CALL, nonce, from)
openSnackbar('Transaction has been confirmed', 'success') openSnackbar('Transaction has been confirmed', 'success')
} else { } else {
// txHash = await approveTransaction(safeAddress, to, valueInWei, txData, CALL, nonce) // txHash = await approveTransaction(safeAddress, to, valueInWei, txData, CALL, nonce)

View File

@ -0,0 +1,8 @@
// @flow
import { createAction } from 'redux-actions'
export const REMOVE_SAFE = 'REMOVE_SAFE'
const removeSafe = createAction<string, *>(REMOVE_SAFE)
export default removeSafe

View File

@ -1,9 +1,10 @@
// @flow // @flow
import { ADD_SAFE } from '~/routes/safe/store/actions/addSafe' import { ADD_SAFE } from '~/routes/safe/store/actions/addSafe'
import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe' import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe'
import { REMOVE_SAFE } from '~/routes/safe/store/actions/removeSafe'
import type { Store, AnyAction } from 'redux' import type { Store, AnyAction } from 'redux'
import { type GlobalState } from '~/store/' import { type GlobalState } from '~/store/'
import { saveSafes, setOwners } from '~/logic/safe/utils' import { saveSafes, setOwners, removeOwners } from '~/logic/safe/utils'
import { safesMapSelector } from '~/routes/safeList/store/selectors' import { safesMapSelector } from '~/routes/safeList/store/selectors'
import { getActiveTokensAddressesForAllSafes } from '~/routes/safe/store/selectors' import { getActiveTokensAddressesForAllSafes } from '~/routes/safe/store/selectors'
import { tokensSelector } from '~/logic/tokens/store/selectors' import { tokensSelector } from '~/logic/tokens/store/selectors'
@ -11,7 +12,7 @@ import type { Token } from '~/logic/tokens/store/model/token'
import { saveActiveTokens } from '~/logic/tokens/utils/tokensStorage' import { saveActiveTokens } from '~/logic/tokens/utils/tokensStorage'
import { ACTIVATE_TOKEN_FOR_ALL_SAFES } from '~/routes/safe/store/actions/activateTokenForAllSafes' import { ACTIVATE_TOKEN_FOR_ALL_SAFES } from '~/routes/safe/store/actions/activateTokenForAllSafes'
const watchedActions = [ADD_SAFE, UPDATE_SAFE, ACTIVATE_TOKEN_FOR_ALL_SAFES] const watchedActions = [ADD_SAFE, UPDATE_SAFE, REMOVE_SAFE, ACTIVATE_TOKEN_FOR_ALL_SAFES]
const safeStorageMware = (store: Store<GlobalState>) => (next: Function) => async (action: AnyAction) => { const safeStorageMware = (store: Store<GlobalState>) => (next: Function) => async (action: AnyAction) => {
const handledAction = next(action) const handledAction = next(action)
@ -40,6 +41,9 @@ const safeStorageMware = (store: Store<GlobalState>) => (next: Function) => asyn
if (action.type === ADD_SAFE) { if (action.type === ADD_SAFE) {
const { safe } = action.payload const { safe } = action.payload
setOwners(safe.address, safe.owners) setOwners(safe.address, safe.owners)
} else if (action.type === REMOVE_SAFE) {
const safeAddress = action.payload
removeOwners(safeAddress)
} }
} }

View File

@ -9,6 +9,7 @@ import { loadFromStorage } from '~/utils/storage'
import { SAFES_KEY } from '~/logic/safe/utils' import { SAFES_KEY } from '~/logic/safe/utils'
import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe' import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe'
import { ACTIVATE_TOKEN_FOR_ALL_SAFES } from '~/routes/safe/store/actions/activateTokenForAllSafes' import { ACTIVATE_TOKEN_FOR_ALL_SAFES } from '~/routes/safe/store/actions/activateTokenForAllSafes'
import { REMOVE_SAFE } from '~/routes/safe/store/actions/removeSafe'
export const SAFE_REDUCER_ID = 'safes' export const SAFE_REDUCER_ID = 'safes'
@ -91,6 +92,11 @@ export default handleActions<State, *>(
return state.set(safe.address, SafeRecord(safe)) return state.set(safe.address, SafeRecord(safe))
}, },
[REMOVE_SAFE]: (state: State, action: ActionType<Function>): State => {
const safeAddress = action.payload
return state.delete(safeAddress)
},
}, },
Map(), Map(),
) )

View File

@ -12,7 +12,7 @@ import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
import { sleep } from '~/utils/timer' import { sleep } from '~/utils/timer'
import { history } from '~/store' import { history } from '~/store'
import AppRoutes from '~/routes' import AppRoutes from '~/routes'
import { SAFELIST_ADDRESS, SETTINS_ADDRESS } from '~/routes/routes' import { SAFELIST_ADDRESS } from '~/routes/routes'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions' import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
export const EXPAND_BALANCE_INDEX = 0 export const EXPAND_BALANCE_INDEX = 0

View File

@ -7,6 +7,7 @@ import {
disabled, disabled,
primary, primary,
secondary, secondary,
error,
md, md,
lg, lg,
bolderFont, bolderFont,
@ -27,7 +28,7 @@ const palette = {
main: secondary, main: secondary,
}, },
error: { error: {
main: '#FB4F62', main: error,
}, },
contrastThreshold: 3, contrastThreshold: 3,
tonalOffset: 0.2, tonalOffset: 0.2,

View File

@ -7,6 +7,7 @@ const tertiary = '#f6f9fc'
const fontColor = '#4a5579' const fontColor = '#4a5579'
const fancyColor = '#fd7890' const fancyColor = '#fd7890'
const warningColor = '#ffc05f' const warningColor = '#ffc05f'
const errorColor = '#fb4f62'
const connectedColor = '#00c4c4' const connectedColor = '#00c4c4'
const disabled = '#65707e' const disabled = '#65707e'
const xs = '4px' const xs = '4px'
@ -28,6 +29,7 @@ module.exports = Object.assign(
fontColor, fontColor,
fancy: fancyColor, fancy: fancyColor,
warning: warningColor, warning: warningColor,
error: errorColor,
connected: connectedColor, connected: connectedColor,
xs, xs,
sm, sm,

View File

@ -31,3 +31,11 @@ export const saveToStorage = async (key: string, value: *): Promise<*> => {
console.error(`Failed to save ${key} in the storage:`, err) console.error(`Failed to save ${key} in the storage:`, err)
} }
} }
export const removeFromStorage = async (key: string): Promise<*> => {
try {
await storage.remove(`${PREFIX}__${key}`)
} catch (err) {
console.error(`Failed to remove ${key} from the storage:`, err)
}
}

18765
yarn-error.log Normal file

File diff suppressed because it is too large Load Diff

906
yarn.lock

File diff suppressed because it is too large Load Diff