Merge branch 'development' into issue-773
This commit is contained in:
commit
c5a1e40216
|
@ -5,3 +5,5 @@ build/
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
.env*
|
.env*
|
||||||
.idea/
|
.idea/
|
||||||
|
.yalc/
|
||||||
|
yalc.lock
|
|
@ -44,6 +44,7 @@ deploy:
|
||||||
skip_cleanup: true
|
skip_cleanup: true
|
||||||
local_dir: build_webpack
|
local_dir: build_webpack
|
||||||
upload-dir: app
|
upload-dir: app
|
||||||
|
region: $AWS_DEFAULT_REGION
|
||||||
on:
|
on:
|
||||||
branch: development
|
branch: development
|
||||||
|
|
||||||
|
@ -55,6 +56,7 @@ deploy:
|
||||||
skip_cleanup: true
|
skip_cleanup: true
|
||||||
local_dir: build_webpack
|
local_dir: build_webpack
|
||||||
upload-dir: current/app
|
upload-dir: current/app
|
||||||
|
region: $AWS_DEFAULT_REGION
|
||||||
on:
|
on:
|
||||||
branch: master
|
branch: master
|
||||||
|
|
||||||
|
@ -66,6 +68,7 @@ deploy:
|
||||||
skip_cleanup: true
|
skip_cleanup: true
|
||||||
local_dir: build_webpack
|
local_dir: build_webpack
|
||||||
upload-dir: releases/$TRAVIS_TAG
|
upload-dir: releases/$TRAVIS_TAG
|
||||||
|
region: $AWS_DEFAULT_REGION
|
||||||
on:
|
on:
|
||||||
tags: true
|
tags: true
|
||||||
- provider: script
|
- provider: script
|
||||||
|
|
|
@ -43,6 +43,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gnosis.pm/safe-contracts": "1.1.1-dev.1",
|
"@gnosis.pm/safe-contracts": "1.1.1-dev.1",
|
||||||
"@gnosis.pm/util-contracts": "2.0.6",
|
"@gnosis.pm/util-contracts": "2.0.6",
|
||||||
|
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#71e6fed",
|
||||||
"@material-ui/core": "4.9.10",
|
"@material-ui/core": "4.9.10",
|
||||||
"@material-ui/icons": "4.9.1",
|
"@material-ui/icons": "4.9.1",
|
||||||
"@material-ui/lab": "4.0.0-alpha.39",
|
"@material-ui/lab": "4.0.0-alpha.39",
|
||||||
|
|
|
@ -4,14 +4,14 @@ import styled from 'styled-components'
|
||||||
export const Wrapper = styled.div`
|
export const Wrapper = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 245px auto;
|
grid-template-columns: 245px auto;
|
||||||
grid-template-rows: 62px 500px 25px;
|
grid-template-rows: 500px;
|
||||||
min-height: 500px;
|
min-height: 525px;
|
||||||
|
|
||||||
.background {
|
.background {
|
||||||
box-shadow: 1px 2px 10px 0 rgba(212, 212, 211, 0.59);
|
box-shadow: 1px 2px 10px 0 rgba(212, 212, 211, 0.59);
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
`
|
|
||||||
export const Nav = styled.div`
|
export const Nav = styled.div`
|
||||||
grid-column: 1/3;
|
grid-column: 1/3;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
|
@ -24,23 +24,24 @@ export const Nav = styled.div`
|
||||||
|
|
||||||
export const Menu = styled.div.attrs(() => ({ className: 'background' }))`
|
export const Menu = styled.div.attrs(() => ({ className: 'background' }))`
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2/4;
|
|
||||||
border-right: solid 2px #e8e7e6;
|
border-right: solid 2px #e8e7e6;
|
||||||
border-top-left-radius: 8px;
|
border-top-left-radius: 8px;
|
||||||
border-bottom-left-radius: 8px;
|
border-bottom-left-radius: 8px;
|
||||||
|
background-color: white;
|
||||||
`
|
`
|
||||||
|
|
||||||
export const Content = styled.div.attrs(() => ({ className: 'background' }))`
|
export const Content = styled.div.attrs(() => ({ className: 'background' }))`
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 2;
|
|
||||||
border-top-right-radius: 8px;
|
border-top-right-radius: 8px;
|
||||||
|
background-color: white;
|
||||||
`
|
`
|
||||||
|
|
||||||
export const Footer = styled.div.attrs(() => ({ className: 'background' }))`
|
export const Footer = styled.div.attrs(() => ({ className: 'background' }))`
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 3;
|
grid-row: 2;
|
||||||
border-bottom-right-radius: 8px;
|
border-bottom-right-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
background-color: white;
|
||||||
`
|
`
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
// @flow
|
// @flow
|
||||||
import 'babel-polyfill'
|
import 'babel-polyfill'
|
||||||
|
|
||||||
|
import { theme as styledTheme } from '@gnosis.pm/safe-react-components'
|
||||||
import { MuiThemeProvider } from '@material-ui/core/styles'
|
import { MuiThemeProvider } from '@material-ui/core/styles'
|
||||||
import { ConnectedRouter } from 'connected-react-router'
|
import { ConnectedRouter } from 'connected-react-router'
|
||||||
import React, { Suspense } from 'react'
|
import React, { Suspense } from 'react'
|
||||||
import { hot } from 'react-hot-loader/root'
|
import { hot } from 'react-hot-loader/root'
|
||||||
import { Provider } from 'react-redux'
|
import { Provider } from 'react-redux'
|
||||||
|
import { ThemeProvider } from 'styled-components'
|
||||||
|
|
||||||
import Loader from '../Loader'
|
import Loader from '../Loader'
|
||||||
import PageFrame from '../layout/PageFrame'
|
import PageFrame from '../layout/PageFrame'
|
||||||
|
@ -18,17 +20,19 @@ import './index.scss'
|
||||||
import './OnboardCustom.scss'
|
import './OnboardCustom.scss'
|
||||||
|
|
||||||
const Root = () => (
|
const Root = () => (
|
||||||
<Provider store={store}>
|
<ThemeProvider theme={styledTheme}>
|
||||||
<MuiThemeProvider theme={theme}>
|
<Provider store={store}>
|
||||||
<ConnectedRouter history={history}>
|
<MuiThemeProvider theme={theme}>
|
||||||
<PageFrame>
|
<ConnectedRouter history={history}>
|
||||||
<Suspense fallback={<Loader />}>
|
<PageFrame>
|
||||||
<AppRoutes />
|
<Suspense fallback={<Loader />}>
|
||||||
</Suspense>
|
<AppRoutes />
|
||||||
</PageFrame>
|
</Suspense>
|
||||||
</ConnectedRouter>
|
</PageFrame>
|
||||||
</MuiThemeProvider>
|
</ConnectedRouter>
|
||||||
</Provider>
|
</MuiThemeProvider>
|
||||||
|
</Provider>
|
||||||
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default hot(Root)
|
export default hot(Root)
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
// source: https://github.com/final-form/react-final-form/issues/369#issuecomment-439823584
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Field } from 'react-final-form'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
validate: () => void,
|
||||||
|
debounce: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DebounceValidationField = ({ debounce = 1000, validate, ...rest }: Props) => {
|
||||||
|
let clearTimeout
|
||||||
|
|
||||||
|
const localValidation = (value, values, fieldState) => {
|
||||||
|
if (fieldState.active) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (clearTimeout) clearTimeout()
|
||||||
|
const timerId = setTimeout(() => {
|
||||||
|
resolve(validate(value, values, fieldState))
|
||||||
|
}, debounce)
|
||||||
|
clearTimeout = () => {
|
||||||
|
clearTimeout(timerId)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return validate(value, values, fieldState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Field {...rest} validate={localValidation} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DebounceValidationField
|
|
@ -88,8 +88,12 @@ export const uniqueAddress = (addresses: string[] | List<string>) =>
|
||||||
return addressAlreadyExists ? ADDRESS_REPEATED_ERROR : undefined
|
return addressAlreadyExists ? ADDRESS_REPEATED_ERROR : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
export const composeValidators = (...validators: Function[]): FieldValidator => (value: Field) =>
|
export const composeValidators = (...validators: Function[]): FieldValidator => (value: Field, values, meta) => {
|
||||||
validators.reduce((error, validator) => error || validator(value), undefined)
|
if (!meta.modified) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return validators.reduce((error, validator) => error || validator(value), undefined)
|
||||||
|
}
|
||||||
|
|
||||||
export const inLimit = (limit: number, base: number, baseText: string, symbol: string = 'ETH') => (value: string) => {
|
export const inLimit = (limit: number, base: number, baseText: string, symbol: string = 'ETH') => (value: string) => {
|
||||||
const amount = Number(value)
|
const amount = Number(value)
|
||||||
|
|
|
@ -0,0 +1,197 @@
|
||||||
|
// @flow
|
||||||
|
import { ButtonLink, Checkbox, ManageListModal, Text, TextField } from '@gnosis.pm/safe-react-components'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { FormSpy } from 'react-final-form'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import { getAppInfoFromUrl } from './utils'
|
||||||
|
|
||||||
|
import Field from '~/components/forms/Field'
|
||||||
|
import DebounceValidationField from '~/components/forms/Field/DebounceValidationField'
|
||||||
|
import GnoForm from '~/components/forms/GnoForm'
|
||||||
|
import { composeValidators, required } from '~/components/forms/validator'
|
||||||
|
import Img from '~/components/layout/Img'
|
||||||
|
import appsIconSvg from '~/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||||
|
|
||||||
|
const FORM_ID = 'add-apps-form'
|
||||||
|
|
||||||
|
const StyledText = styled(Text)`
|
||||||
|
margin-bottom: 19px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledTextFileAppName = styled(TextField)`
|
||||||
|
&& {
|
||||||
|
width: 335px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const AppInfo = styled.div`
|
||||||
|
margin: 36px 0 24px 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledCheckbox = styled(Checkbox)`
|
||||||
|
margin: 0;
|
||||||
|
`
|
||||||
|
const APP_INFO = { iconUrl: appsIconSvg, name: '', error: false }
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
appList: Array<{
|
||||||
|
id: string,
|
||||||
|
iconUrl: string,
|
||||||
|
name: string,
|
||||||
|
disabled: boolean,
|
||||||
|
}>,
|
||||||
|
onAppAdded: (app: any) => void,
|
||||||
|
onAppToggle: (appId: string, enabled: boolean) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlValidator = (value: string) => {
|
||||||
|
return /(?:^|[ \t])((https?:\/\/)?(?:localhost|[\w-]+(?:\.[\w-]+)+)(:\d+)?(\/\S*)?)/gm.test(value)
|
||||||
|
? undefined
|
||||||
|
: 'Please, provide a valid url'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
const [appInfo, setAppInfo] = useState(APP_INFO)
|
||||||
|
const [isSubmitDisabled, setIsSubmitDisabled] = useState(true)
|
||||||
|
|
||||||
|
const onItemToggle = (itemId: string, checked: boolean) => {
|
||||||
|
onAppToggle(itemId, checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setIsOpen(false)
|
||||||
|
onAppAdded(appInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanAppInfo = () => setAppInfo(APP_INFO)
|
||||||
|
|
||||||
|
const safeAppValidator = async (value) => {
|
||||||
|
const appInfo = await getAppInfoFromUrl(value)
|
||||||
|
|
||||||
|
if (appInfo.error) {
|
||||||
|
setAppInfo(APP_INFO)
|
||||||
|
return 'This is not a valid Safe app.'
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppInfo({ ...appInfo })
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueAppValidator = (value) => {
|
||||||
|
const exists = appList.find((a) => a.url === value.trim())
|
||||||
|
return exists ? 'This app is already registered.' : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFormStatusChange = ({ pristine, valid, validating }) => {
|
||||||
|
if (!pristine) {
|
||||||
|
setIsSubmitDisabled(validating || !valid || appInfo.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customRequiredValidator = (value) => {
|
||||||
|
if (!value || !value.length) {
|
||||||
|
setAppInfo(APP_INFO)
|
||||||
|
return 'Required'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAddAppForm = () => {
|
||||||
|
return (
|
||||||
|
<GnoForm
|
||||||
|
initialValues={{
|
||||||
|
appUrl: '',
|
||||||
|
agreed: false,
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
testId={FORM_ID}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<>
|
||||||
|
<StyledText size="xl">Add custom app</StyledText>
|
||||||
|
<DebounceValidationField
|
||||||
|
component={TextField}
|
||||||
|
label="App URL"
|
||||||
|
name="appUrl"
|
||||||
|
placeholder="App URL"
|
||||||
|
type="text"
|
||||||
|
validate={composeValidators(customRequiredValidator, urlValidator, uniqueAppValidator, safeAppValidator)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppInfo>
|
||||||
|
<Img alt="Token image" height={55} src={appInfo.iconUrl} />
|
||||||
|
<StyledTextFileAppName label="App name" readOnly value={appInfo.name} />
|
||||||
|
</AppInfo>
|
||||||
|
|
||||||
|
<FormSpy
|
||||||
|
onChange={onFormStatusChange}
|
||||||
|
subscription={{
|
||||||
|
valid: true,
|
||||||
|
pristine: true,
|
||||||
|
validating: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
component={StyledCheckbox}
|
||||||
|
label={
|
||||||
|
<p>
|
||||||
|
This app is not a Gnosis product and I agree to use this app <br /> at my own risk.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
name="agreed"
|
||||||
|
type="checkbox"
|
||||||
|
validate={required}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</GnoForm>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmitForm = () => {
|
||||||
|
// This sucks, but it's the way the docs suggest
|
||||||
|
// https://github.com/final-form/react-final-form/blob/master/docs/faq.md#via-documentgetelementbyid
|
||||||
|
document.querySelectorAll(`[data-testId=${FORM_ID}]`)[0].dispatchEvent(new Event('submit', { cancelable: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOpen = () => setIsOpen(!isOpen)
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setIsOpen(false)
|
||||||
|
cleanAppInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemList = () =>
|
||||||
|
appList.map((a) => {
|
||||||
|
return { ...a, checked: !a.disabled }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ButtonLink color="primary" onClick={toggleOpen}>
|
||||||
|
Manage Apps
|
||||||
|
</ButtonLink>
|
||||||
|
{isOpen && (
|
||||||
|
<ManageListModal
|
||||||
|
addButtonLabel="Add custom app"
|
||||||
|
defaultIconUrl={appsIconSvg}
|
||||||
|
formBody={getAddAppForm()}
|
||||||
|
formSubmitLabel="Save"
|
||||||
|
isSubmitFormDisabled={isSubmitDisabled}
|
||||||
|
itemList={getItemList()}
|
||||||
|
onClose={closeModal}
|
||||||
|
onItemToggle={onItemToggle}
|
||||||
|
onSubmitForm={onSubmitForm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ManageApps
|
|
@ -1,25 +1,37 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
import { Card, FixedDialog, FixedIcon, IconText, Menu, Text, Title } from '@gnosis.pm/safe-react-components'
|
||||||
import { withSnackbar } from 'notistack'
|
import { withSnackbar } from 'notistack'
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { withRouter } from 'react-router-dom'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import ManageApps from './ManageApps'
|
||||||
import confirmTransactions from './confirmTransactions'
|
import confirmTransactions from './confirmTransactions'
|
||||||
import sendTransactions from './sendTransactions'
|
import sendTransactions from './sendTransactions'
|
||||||
import { GNOSIS_APPS_URL, getAppInfoFromUrl } from './utils'
|
import { getAppInfoFromUrl, staticAppsList } from './utils'
|
||||||
|
|
||||||
import { ListContentLayout as LCL, Loader } from '~/components-v2'
|
import { ListContentLayout as LCL, Loader } from '~/components-v2'
|
||||||
import ButtonLink from '~/components/layout/ButtonLink'
|
import { SAFELIST_ADDRESS } from '~/routes/routes'
|
||||||
|
import { loadFromStorage, saveToStorage } from '~/utils/storage'
|
||||||
|
|
||||||
|
const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
|
||||||
|
const APPS_LEGAL_DISCLAIMER_STORAGE_KEY = 'APPS_LEGAL_DISCLAIMER_STORAGE_KEY'
|
||||||
|
|
||||||
const StyledIframe = styled.iframe`
|
const StyledIframe = styled.iframe`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: ${(props) => (props.shouldDisplay ? 'block' : 'none')};
|
display: ${(props) => (props.shouldDisplay ? 'block' : 'none')};
|
||||||
`
|
`
|
||||||
|
const Centered = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
`
|
||||||
|
|
||||||
const operations = {
|
const operations = {
|
||||||
SEND_TRANSACTIONS: 'sendTransactions',
|
SEND_TRANSACTIONS: 'SEND_TRANSACTIONS',
|
||||||
GET_TRANSACTIONS: 'getTransactions',
|
ON_SAFE_INFO: 'ON_SAFE_INFO',
|
||||||
ON_SAFE_INFO: 'onSafeInfo',
|
|
||||||
ON_TX_UPDATE: 'onTransactionUpdate',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -27,7 +39,9 @@ type Props = {
|
||||||
safeAddress: String,
|
safeAddress: String,
|
||||||
safeName: String,
|
safeName: String,
|
||||||
ethBalance: String,
|
ethBalance: String,
|
||||||
|
history: Object,
|
||||||
network: String,
|
network: String,
|
||||||
|
granted: Boolean,
|
||||||
createTransaction: any,
|
createTransaction: any,
|
||||||
enqueueSnackbar: Function,
|
enqueueSnackbar: Function,
|
||||||
closeSnackbar: Function,
|
closeSnackbar: Function,
|
||||||
|
@ -41,19 +55,22 @@ function Apps({
|
||||||
createTransaction,
|
createTransaction,
|
||||||
enqueueSnackbar,
|
enqueueSnackbar,
|
||||||
ethBalance,
|
ethBalance,
|
||||||
|
granted,
|
||||||
|
history,
|
||||||
network,
|
network,
|
||||||
openModal,
|
openModal,
|
||||||
safeAddress,
|
safeAddress,
|
||||||
safeName,
|
safeName,
|
||||||
web3,
|
web3,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [appsList, setAppsList] = useState([])
|
const [appList, setAppList] = useState([])
|
||||||
|
const [legalDisclaimerAccepted, setLegalDisclaimerAccepted] = useState(false)
|
||||||
const [selectedApp, setSelectedApp] = useState()
|
const [selectedApp, setSelectedApp] = useState()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [appIsLoading, setAppIsLoading] = useState(true)
|
const [appIsLoading, setAppIsLoading] = useState(true)
|
||||||
const [iframeEl, setframeEl] = useState(null)
|
const [iframeEl, setIframeEl] = useState(null)
|
||||||
|
|
||||||
const getSelectedApp = () => appsList.find((e) => e.id === selectedApp)
|
const getSelectedApp = () => appList.find((e) => e.id === selectedApp)
|
||||||
|
|
||||||
const sendMessageToIframe = (messageId, data) => {
|
const sendMessageToIframe = (messageId, data) => {
|
||||||
iframeEl.contentWindow.postMessage({ messageId, data }, getSelectedApp().url)
|
iframeEl.contentWindow.postMessage({ messageId, data }, getSelectedApp().url)
|
||||||
|
@ -61,7 +78,7 @@ function Apps({
|
||||||
|
|
||||||
const handleIframeMessage = async (data) => {
|
const handleIframeMessage = async (data) => {
|
||||||
if (!data || !data.messageId) {
|
if (!data || !data.messageId) {
|
||||||
console.warn('iframe: message without messageId')
|
console.error('ThirdPartyApp: A message was received without message id.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,7 +87,7 @@ function Apps({
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
closeModal()
|
closeModal()
|
||||||
|
|
||||||
const txHash = await sendTransactions(
|
await sendTransactions(
|
||||||
web3,
|
web3,
|
||||||
createTransaction,
|
createTransaction,
|
||||||
safeAddress,
|
safeAddress,
|
||||||
|
@ -79,13 +96,6 @@ function Apps({
|
||||||
closeSnackbar,
|
closeSnackbar,
|
||||||
getSelectedApp().id,
|
getSelectedApp().id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (txHash) {
|
|
||||||
sendMessageToIframe(operations.ON_TX_UPDATE, {
|
|
||||||
txHash,
|
|
||||||
status: 'pending',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmTransactions(
|
confirmTransactions(
|
||||||
|
@ -101,11 +111,9 @@ function Apps({
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case operations.GET_TRANSACTIONS:
|
|
||||||
break
|
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
console.warn(`Iframe:${data.messageId} unkown`)
|
console.error(`ThirdPartyApp: A message was received with an unknown message id ${data.messageId}.`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -113,10 +121,141 @@ function Apps({
|
||||||
|
|
||||||
const iframeRef = useCallback((node) => {
|
const iframeRef = useCallback((node) => {
|
||||||
if (node !== null) {
|
if (node !== null) {
|
||||||
setframeEl(node)
|
setIframeEl(node)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const onSelectApp = (appId) => {
|
||||||
|
const selectedApp = getSelectedApp()
|
||||||
|
|
||||||
|
if (selectedApp && selectedApp.id === appId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppIsLoading(true)
|
||||||
|
setSelectedApp(appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`)
|
||||||
|
|
||||||
|
const onAcceptLegalDisclaimer = () => {
|
||||||
|
setLegalDisclaimerAccepted(true)
|
||||||
|
saveToStorage(APPS_LEGAL_DISCLAIMER_STORAGE_KEY, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContent = () => {
|
||||||
|
if (!selectedApp) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!legalDisclaimerAccepted) {
|
||||||
|
return (
|
||||||
|
<FixedDialog
|
||||||
|
body={
|
||||||
|
<>
|
||||||
|
<Text size="md">
|
||||||
|
You are now accessing third-party apps, which we do not own, control, maintain or audit. We are not
|
||||||
|
liable for any loss you may suffer in connection with interacting with the apps, which is at your own
|
||||||
|
risk. You must read our Terms, which contain more detailed provisions binding on you relating to the
|
||||||
|
apps.
|
||||||
|
</Text>
|
||||||
|
<br />
|
||||||
|
<Text size="md">
|
||||||
|
I have read and understood the{' '}
|
||||||
|
<a href="https://gnosis-safe.io/terms" rel="noopener noreferrer" target="_blank">
|
||||||
|
Terms
|
||||||
|
</a>{' '}
|
||||||
|
and this Disclaimer, and agree to be bound by .
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onCancel={redirectToBalance}
|
||||||
|
onConfirm={onAcceptLegalDisclaimer}
|
||||||
|
title="Disclaimer"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (network === 'UNKNOWN' || !granted) {
|
||||||
|
return (
|
||||||
|
<Centered style={{ height: '476px' }}>
|
||||||
|
<FixedIcon type="notOwner" />
|
||||||
|
<Title size="xs">To use apps, you must be an owner of this Safe</Title>
|
||||||
|
</Centered>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{appIsLoading && <Loader />}
|
||||||
|
<StyledIframe
|
||||||
|
frameBorder="0"
|
||||||
|
id="iframeId"
|
||||||
|
ref={iframeRef}
|
||||||
|
shouldDisplay={!appIsLoading}
|
||||||
|
src={getSelectedApp().url}
|
||||||
|
title={getSelectedApp().name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAppAdded = (app) => {
|
||||||
|
const newAppList = [
|
||||||
|
{ url: app.url, disabled: false },
|
||||||
|
...appList.map((a) => ({
|
||||||
|
url: a.url,
|
||||||
|
disabled: a.disabled,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
saveToStorage(APPS_STORAGE_KEY, newAppList)
|
||||||
|
|
||||||
|
setAppList([...appList, { ...app, disabled: false }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFirstApp = (apps) => {
|
||||||
|
const firstEnabledApp = apps.find((a) => !a.disabled)
|
||||||
|
if (firstEnabledApp) {
|
||||||
|
onSelectApp(firstEnabledApp.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAppToggle = async (appId: string, enabled: boolean) => {
|
||||||
|
// update in-memory list
|
||||||
|
const copyAppList = [...appList]
|
||||||
|
|
||||||
|
const app = copyAppList.find((a) => a.id === appId)
|
||||||
|
if (!app) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.disabled = !enabled
|
||||||
|
setAppList(copyAppList)
|
||||||
|
|
||||||
|
// update storage list
|
||||||
|
const persistedAppList = (await loadFromStorage(APPS_STORAGE_KEY)) || []
|
||||||
|
let storageApp = persistedAppList.find((a) => a.url === app.url)
|
||||||
|
|
||||||
|
if (!storageApp) {
|
||||||
|
storageApp = { url: app.url }
|
||||||
|
storageApp.disabled = !enabled
|
||||||
|
persistedAppList.push(storageApp)
|
||||||
|
} else {
|
||||||
|
storageApp.disabled = !enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToStorage(APPS_STORAGE_KEY, persistedAppList)
|
||||||
|
|
||||||
|
// select app
|
||||||
|
const selectedApp = getSelectedApp()
|
||||||
|
if (!selectedApp || (selectedApp && selectedApp.id === appId)) {
|
||||||
|
setSelectedApp(undefined)
|
||||||
|
selectFirstApp(copyAppList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEnabledApps = () => appList.filter((a) => !a.disabled)
|
||||||
|
|
||||||
// handle messages from iframe
|
// handle messages from iframe
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onIframeMessage = async ({ data, origin }) => {
|
const onIframeMessage = async ({ data, origin }) => {
|
||||||
|
@ -125,7 +264,7 @@ function Apps({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!getSelectedApp().url.includes(origin)) {
|
if (!getSelectedApp().url.includes(origin)) {
|
||||||
console.error(`Message from ${origin} is different to the App URL ${getSelectedApp().url}`)
|
console.error(`ThirdPartyApp: A message from was received from an unknown origin ${origin}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,35 +278,63 @@ function Apps({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// load legalDisclaimer
|
||||||
|
useEffect(() => {
|
||||||
|
const checkLegalDisclaimer = async () => {
|
||||||
|
const legalDisclaimer = await loadFromStorage(APPS_LEGAL_DISCLAIMER_STORAGE_KEY)
|
||||||
|
|
||||||
|
if (legalDisclaimer) {
|
||||||
|
setLegalDisclaimerAccepted(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkLegalDisclaimer()
|
||||||
|
})
|
||||||
|
|
||||||
// Load apps list
|
// Load apps list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadApps = async () => {
|
const loadApps = async () => {
|
||||||
const appsUrl = process.env.REACT_APP_GNOSIS_APPS_URL ? process.env.REACT_APP_GNOSIS_APPS_URL : GNOSIS_APPS_URL
|
// recover apps from storage:
|
||||||
const staticAppsList = [`${appsUrl}/compound`, `${appsUrl}/uniswap`]
|
// * third-party apps added by the user
|
||||||
|
// * disabled status for both static and third-party apps
|
||||||
|
const persistedAppList = (await loadFromStorage(APPS_STORAGE_KEY)) || []
|
||||||
|
const list = [...persistedAppList]
|
||||||
|
|
||||||
|
staticAppsList.forEach((staticApp) => {
|
||||||
|
if (!list.some((persistedApp) => persistedApp.url === staticApp.url)) {
|
||||||
|
list.push(staticApp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const list = [...staticAppsList]
|
|
||||||
const apps = []
|
const apps = []
|
||||||
|
// using the appURL to recover app info
|
||||||
for (let index = 0; index < list.length; index++) {
|
for (let index = 0; index < list.length; index++) {
|
||||||
try {
|
try {
|
||||||
const appUrl = list[index]
|
const currentApp = list[index]
|
||||||
const appInfo = await getAppInfoFromUrl(appUrl)
|
|
||||||
const app = { url: appUrl, ...appInfo }
|
|
||||||
|
|
||||||
app.id = JSON.stringify({ url: app.url, name: app.name })
|
const appInfo = await getAppInfoFromUrl(currentApp.url)
|
||||||
apps.push(app)
|
|
||||||
|
if (appInfo.error) {
|
||||||
|
throw Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
appInfo.disabled = currentApp.disabled === undefined ? false : currentApp.disabled
|
||||||
|
|
||||||
|
apps.push(appInfo)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAppsList([...apps])
|
setAppList(apps)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
selectFirstApp(apps)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!appsList.length) {
|
if (!appList.length) {
|
||||||
loadApps()
|
loadApps()
|
||||||
}
|
}
|
||||||
}, [appsList])
|
}, [])
|
||||||
|
|
||||||
// on iframe change
|
// on iframe change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -191,58 +358,54 @@ function Apps({
|
||||||
}
|
}
|
||||||
}, [iframeEl])
|
}, [iframeEl])
|
||||||
|
|
||||||
const onSelectApp = (appId) => {
|
if (loading) {
|
||||||
setAppIsLoading(true)
|
|
||||||
setSelectedApp(appId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getContent = () => {
|
|
||||||
if (!selectedApp) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{appIsLoading && <Loader />}
|
|
||||||
<StyledIframe
|
|
||||||
frameBorder="0"
|
|
||||||
id="iframeId"
|
|
||||||
ref={iframeRef}
|
|
||||||
shouldDisplay={!appIsLoading}
|
|
||||||
src={getSelectedApp().url}
|
|
||||||
title={getSelectedApp().name}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading || !appsList.length) {
|
|
||||||
return <Loader />
|
return <Loader />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LCL.Wrapper>
|
<>
|
||||||
<LCL.Nav>
|
<Menu>
|
||||||
<ButtonLink onClick={() => {}} size="lg" testId="manage-tokens-btn">
|
<ManageApps appList={appList} onAppAdded={onAppAdded} onAppToggle={onAppToggle} />
|
||||||
Manage Apps
|
</Menu>
|
||||||
</ButtonLink>
|
{getEnabledApps().length ? (
|
||||||
</LCL.Nav>
|
<LCL.Wrapper>
|
||||||
<LCL.Menu>
|
<LCL.Menu>
|
||||||
<LCL.List activeItem={selectedApp} items={appsList} onItemClick={onSelectApp} />
|
<LCL.List activeItem={selectedApp} items={getEnabledApps()} onItemClick={onSelectApp} />
|
||||||
</LCL.Menu>
|
</LCL.Menu>
|
||||||
<LCL.Content>{getContent()}</LCL.Content>
|
<LCL.Content>{getContent()}</LCL.Content>
|
||||||
<LCL.Footer>
|
{/* <LCL.Footer>
|
||||||
This App is provided by{' '}
|
{getSelectedApp() && getSelectedApp().providedBy && (
|
||||||
<ButtonLink
|
<>
|
||||||
onClick={() => window.open(getSelectedApp().providedBy.url, '_blank')}
|
<p>This App is provided by </p>
|
||||||
size="lg"
|
<ButtonLink
|
||||||
testId="manage-tokens-btn"
|
onClick={() => window.open(getSelectedApp().providedBy.url, '_blank')}
|
||||||
>
|
size="lg"
|
||||||
{selectedApp && getSelectedApp().providedBy.name}
|
testId="manage-tokens-btn"
|
||||||
</ButtonLink>
|
>
|
||||||
</LCL.Footer>
|
{selectedApp && getSelectedApp().providedBy.name}
|
||||||
</LCL.Wrapper>
|
</ButtonLink>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</LCL.Footer> */}
|
||||||
|
</LCL.Wrapper>
|
||||||
|
) : (
|
||||||
|
<Card style={{ marginBottom: '24px' }}>
|
||||||
|
<Centered>
|
||||||
|
<Title size="xs">No Apps Enabled</Title>
|
||||||
|
</Centered>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<Centered>
|
||||||
|
<IconText
|
||||||
|
color="secondary"
|
||||||
|
iconSize="sm"
|
||||||
|
iconType="info"
|
||||||
|
text="These are third-party apps, which means they are not owned, controlled, maintained or audited by Gnosis. Interacting with the apps is at your own risk."
|
||||||
|
textSize="sm"
|
||||||
|
/>
|
||||||
|
</Centered>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withSnackbar(Apps)
|
export default withSnackbar(withRouter(Apps))
|
||||||
|
|
|
@ -3,34 +3,56 @@ import axios from 'axios'
|
||||||
|
|
||||||
import appsIconSvg from '~/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
import appsIconSvg from '~/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||||
|
|
||||||
export const GNOSIS_APPS_URL = 'https://gnosis-apps.netlify.com'
|
export const GNOSIS_APPS_URL = 'https://gnosis-apps.netlify.app'
|
||||||
|
|
||||||
|
const appsUrl = process.env.REACT_APP_GNOSIS_APPS_URL ? process.env.REACT_APP_GNOSIS_APPS_URL : GNOSIS_APPS_URL
|
||||||
|
export const staticAppsList = [{ url: `${appsUrl}/compound`, disabled: false }]
|
||||||
|
|
||||||
export const getAppInfoFromOrigin = (origin: string) => {
|
export const getAppInfoFromOrigin = (origin: string) => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(origin)
|
return JSON.parse(origin)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Impossible to parse TX origin: ${origin}`)
|
console.error(`Impossible to parse TX from origin: ${origin}`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAppInfoFromUrl = async (appUrl: string) => {
|
export const getAppInfoFromUrl = async (appUrl: string) => {
|
||||||
|
let cleanedUpAppUrl = appUrl.trim()
|
||||||
|
if (cleanedUpAppUrl.substr(-1) === '/') {
|
||||||
|
cleanedUpAppUrl = cleanedUpAppUrl.substr(0, cleanedUpAppUrl.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = { id: undefined, url: cleanedUpAppUrl, name: 'unknown', iconUrl: appsIconSvg, error: true }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appInfo = await axios.get(`${appUrl}/manifest.json`)
|
const appInfo = await axios.get(`${cleanedUpAppUrl}/manifest.json`)
|
||||||
const res = { url: appUrl, ...appInfo.data, iconUrl: appsIconSvg }
|
|
||||||
|
// verify imported app fulfil safe requirements
|
||||||
|
if (!appInfo || !appInfo.data || !appInfo.data.name || !appInfo.data.description) {
|
||||||
|
throw Error('The app does not fulfil the structure required.')
|
||||||
|
}
|
||||||
|
|
||||||
|
res = {
|
||||||
|
...res,
|
||||||
|
...appInfo.data,
|
||||||
|
id: JSON.stringify({ url: cleanedUpAppUrl, name: appInfo.data.name }),
|
||||||
|
error: false,
|
||||||
|
}
|
||||||
if (appInfo.data.iconPath) {
|
if (appInfo.data.iconPath) {
|
||||||
try {
|
try {
|
||||||
const iconInfo = await axios.get(`${appUrl}/${appInfo.data.iconPath}`)
|
const iconInfo = await axios.get(`${cleanedUpAppUrl}/${appInfo.data.iconPath}`)
|
||||||
if (/image\/\w/gm.test(iconInfo.headers['content-type'])) {
|
if (/image\/\w/gm.test(iconInfo.headers['content-type'])) {
|
||||||
res.iconUrl = `${appUrl}/${appInfo.data.iconPath}`
|
res.iconUrl = `${cleanedUpAppUrl}/${appInfo.data.iconPath}`
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`It was not possible to fetch icon from app ${res.name}`)
|
console.error(`It was not possible to fetch icon from app ${cleanedUpAppUrl}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`It was not possible to fetch app from ${appUrl}`)
|
console.error(`It was not possible to fetch app from ${cleanedUpAppUrl}: ${error.message}`)
|
||||||
return null
|
return res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -186,6 +186,7 @@ const Layout = (props: Props) => {
|
||||||
closeModal={closeGenericModal}
|
closeModal={closeGenericModal}
|
||||||
createTransaction={createTransaction}
|
createTransaction={createTransaction}
|
||||||
ethBalance={ethBalance}
|
ethBalance={ethBalance}
|
||||||
|
granted={granted}
|
||||||
network={network}
|
network={network}
|
||||||
openModal={openGenericModal}
|
openModal={openGenericModal}
|
||||||
safeAddress={address}
|
safeAddress={address}
|
||||||
|
@ -349,9 +350,7 @@ const Layout = (props: Props) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{process.env.REACT_APP_ENV !== 'production' && (
|
<Route exact path={`${match.path}/apps`} render={renderAppsTab} />
|
||||||
<Route exact path={`${match.path}/apps`} render={renderAppsTab} />
|
|
||||||
)}
|
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path={`${match.path}/settings`}
|
path={`${match.path}/settings`}
|
||||||
|
|
|
@ -127,11 +127,6 @@ const createTransaction = ({
|
||||||
|
|
||||||
const sendParams = { from, value: 0 }
|
const sendParams = { from, value: 0 }
|
||||||
|
|
||||||
// TODO find a better solution for this in dev and production.
|
|
||||||
if (process.env.REACT_APP_ENV !== 'production') {
|
|
||||||
sendParams.gasLimit = 1000000
|
|
||||||
}
|
|
||||||
|
|
||||||
// if not set owner management tests will fail on ganache
|
// if not set owner management tests will fail on ganache
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
sendParams.gas = '7000000'
|
sendParams.gas = '7000000'
|
||||||
|
|
|
@ -110,11 +110,6 @@ const processTransaction = ({
|
||||||
|
|
||||||
const sendParams = { from, value: 0 }
|
const sendParams = { from, value: 0 }
|
||||||
|
|
||||||
// TODO find a better solution for this in dev and production.
|
|
||||||
if (process.env.REACT_APP_ENV !== 'production') {
|
|
||||||
sendParams.gasLimit = 1000000
|
|
||||||
}
|
|
||||||
|
|
||||||
// if not set owner management tests will fail on ganache
|
// if not set owner management tests will fail on ganache
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
sendParams.gas = '7000000'
|
sendParams.gas = '7000000'
|
||||||
|
|
Loading…
Reference in New Issue