Merge branch 'development' into issue-773
This commit is contained in:
commit
c5a1e40216
|
@ -5,3 +5,5 @@ build/
|
|||
yarn-error.log
|
||||
.env*
|
||||
.idea/
|
||||
.yalc/
|
||||
yalc.lock
|
|
@ -44,6 +44,7 @@ deploy:
|
|||
skip_cleanup: true
|
||||
local_dir: build_webpack
|
||||
upload-dir: app
|
||||
region: $AWS_DEFAULT_REGION
|
||||
on:
|
||||
branch: development
|
||||
|
||||
|
@ -55,6 +56,7 @@ deploy:
|
|||
skip_cleanup: true
|
||||
local_dir: build_webpack
|
||||
upload-dir: current/app
|
||||
region: $AWS_DEFAULT_REGION
|
||||
on:
|
||||
branch: master
|
||||
|
||||
|
@ -66,6 +68,7 @@ deploy:
|
|||
skip_cleanup: true
|
||||
local_dir: build_webpack
|
||||
upload-dir: releases/$TRAVIS_TAG
|
||||
region: $AWS_DEFAULT_REGION
|
||||
on:
|
||||
tags: true
|
||||
- provider: script
|
||||
|
|
|
@ -43,6 +43,7 @@
|
|||
"dependencies": {
|
||||
"@gnosis.pm/safe-contracts": "1.1.1-dev.1",
|
||||
"@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/icons": "4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.39",
|
||||
|
|
|
@ -4,14 +4,14 @@ import styled from 'styled-components'
|
|||
export const Wrapper = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 245px auto;
|
||||
grid-template-rows: 62px 500px 25px;
|
||||
min-height: 500px;
|
||||
grid-template-rows: 500px;
|
||||
min-height: 525px;
|
||||
|
||||
.background {
|
||||
box-shadow: 1px 2px 10px 0 rgba(212, 212, 211, 0.59);
|
||||
background-color: white;
|
||||
}
|
||||
`
|
||||
|
||||
export const Nav = styled.div`
|
||||
grid-column: 1/3;
|
||||
grid-row: 1;
|
||||
|
@ -24,23 +24,24 @@ export const Nav = styled.div`
|
|||
|
||||
export const Menu = styled.div.attrs(() => ({ className: 'background' }))`
|
||||
grid-column: 1;
|
||||
grid-row: 2/4;
|
||||
border-right: solid 2px #e8e7e6;
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
background-color: white;
|
||||
`
|
||||
|
||||
export const Content = styled.div.attrs(() => ({ className: 'background' }))`
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
border-top-right-radius: 8px;
|
||||
background-color: white;
|
||||
`
|
||||
|
||||
export const Footer = styled.div.attrs(() => ({ className: 'background' }))`
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
grid-row: 2;
|
||||
border-bottom-right-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: white;
|
||||
`
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
// @flow
|
||||
import 'babel-polyfill'
|
||||
|
||||
import { theme as styledTheme } from '@gnosis.pm/safe-react-components'
|
||||
import { MuiThemeProvider } from '@material-ui/core/styles'
|
||||
import { ConnectedRouter } from 'connected-react-router'
|
||||
import React, { Suspense } from 'react'
|
||||
import { hot } from 'react-hot-loader/root'
|
||||
import { Provider } from 'react-redux'
|
||||
import { ThemeProvider } from 'styled-components'
|
||||
|
||||
import Loader from '../Loader'
|
||||
import PageFrame from '../layout/PageFrame'
|
||||
|
@ -18,17 +20,19 @@ import './index.scss'
|
|||
import './OnboardCustom.scss'
|
||||
|
||||
const Root = () => (
|
||||
<Provider store={store}>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<ConnectedRouter history={history}>
|
||||
<PageFrame>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<AppRoutes />
|
||||
</Suspense>
|
||||
</PageFrame>
|
||||
</ConnectedRouter>
|
||||
</MuiThemeProvider>
|
||||
</Provider>
|
||||
<ThemeProvider theme={styledTheme}>
|
||||
<Provider store={store}>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<ConnectedRouter history={history}>
|
||||
<PageFrame>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<AppRoutes />
|
||||
</Suspense>
|
||||
</PageFrame>
|
||||
</ConnectedRouter>
|
||||
</MuiThemeProvider>
|
||||
</Provider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
export const composeValidators = (...validators: Function[]): FieldValidator => (value: Field) =>
|
||||
validators.reduce((error, validator) => error || validator(value), undefined)
|
||||
export const composeValidators = (...validators: Function[]): FieldValidator => (value: Field, values, meta) => {
|
||||
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) => {
|
||||
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
|
||||
import { Card, FixedDialog, FixedIcon, IconText, Menu, Text, Title } from '@gnosis.pm/safe-react-components'
|
||||
import { withSnackbar } from 'notistack'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import ManageApps from './ManageApps'
|
||||
import confirmTransactions from './confirmTransactions'
|
||||
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 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`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: ${(props) => (props.shouldDisplay ? 'block' : 'none')};
|
||||
`
|
||||
const Centered = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const operations = {
|
||||
SEND_TRANSACTIONS: 'sendTransactions',
|
||||
GET_TRANSACTIONS: 'getTransactions',
|
||||
ON_SAFE_INFO: 'onSafeInfo',
|
||||
ON_TX_UPDATE: 'onTransactionUpdate',
|
||||
SEND_TRANSACTIONS: 'SEND_TRANSACTIONS',
|
||||
ON_SAFE_INFO: 'ON_SAFE_INFO',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
|
@ -27,7 +39,9 @@ type Props = {
|
|||
safeAddress: String,
|
||||
safeName: String,
|
||||
ethBalance: String,
|
||||
history: Object,
|
||||
network: String,
|
||||
granted: Boolean,
|
||||
createTransaction: any,
|
||||
enqueueSnackbar: Function,
|
||||
closeSnackbar: Function,
|
||||
|
@ -41,19 +55,22 @@ function Apps({
|
|||
createTransaction,
|
||||
enqueueSnackbar,
|
||||
ethBalance,
|
||||
granted,
|
||||
history,
|
||||
network,
|
||||
openModal,
|
||||
safeAddress,
|
||||
safeName,
|
||||
web3,
|
||||
}: Props) {
|
||||
const [appsList, setAppsList] = useState([])
|
||||
const [appList, setAppList] = useState([])
|
||||
const [legalDisclaimerAccepted, setLegalDisclaimerAccepted] = useState(false)
|
||||
const [selectedApp, setSelectedApp] = useState()
|
||||
const [loading, setLoading] = 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) => {
|
||||
iframeEl.contentWindow.postMessage({ messageId, data }, getSelectedApp().url)
|
||||
|
@ -61,7 +78,7 @@ function Apps({
|
|||
|
||||
const handleIframeMessage = async (data) => {
|
||||
if (!data || !data.messageId) {
|
||||
console.warn('iframe: message without messageId')
|
||||
console.error('ThirdPartyApp: A message was received without message id.')
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -70,7 +87,7 @@ function Apps({
|
|||
const onConfirm = async () => {
|
||||
closeModal()
|
||||
|
||||
const txHash = await sendTransactions(
|
||||
await sendTransactions(
|
||||
web3,
|
||||
createTransaction,
|
||||
safeAddress,
|
||||
|
@ -79,13 +96,6 @@ function Apps({
|
|||
closeSnackbar,
|
||||
getSelectedApp().id,
|
||||
)
|
||||
|
||||
if (txHash) {
|
||||
sendMessageToIframe(operations.ON_TX_UPDATE, {
|
||||
txHash,
|
||||
status: 'pending',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
confirmTransactions(
|
||||
|
@ -101,11 +111,9 @@ function Apps({
|
|||
|
||||
break
|
||||
}
|
||||
case operations.GET_TRANSACTIONS:
|
||||
break
|
||||
|
||||
default: {
|
||||
console.warn(`Iframe:${data.messageId} unkown`)
|
||||
console.error(`ThirdPartyApp: A message was received with an unknown message id ${data.messageId}.`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
@ -113,10 +121,141 @@ function Apps({
|
|||
|
||||
const iframeRef = useCallback((node) => {
|
||||
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
|
||||
useEffect(() => {
|
||||
const onIframeMessage = async ({ data, origin }) => {
|
||||
|
@ -125,7 +264,7 @@ function Apps({
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
useEffect(() => {
|
||||
const loadApps = async () => {
|
||||
const appsUrl = process.env.REACT_APP_GNOSIS_APPS_URL ? process.env.REACT_APP_GNOSIS_APPS_URL : GNOSIS_APPS_URL
|
||||
const staticAppsList = [`${appsUrl}/compound`, `${appsUrl}/uniswap`]
|
||||
// recover apps from storage:
|
||||
// * 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 = []
|
||||
// using the appURL to recover app info
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
try {
|
||||
const appUrl = list[index]
|
||||
const appInfo = await getAppInfoFromUrl(appUrl)
|
||||
const app = { url: appUrl, ...appInfo }
|
||||
const currentApp = list[index]
|
||||
|
||||
app.id = JSON.stringify({ url: app.url, name: app.name })
|
||||
apps.push(app)
|
||||
const appInfo = await getAppInfoFromUrl(currentApp.url)
|
||||
|
||||
if (appInfo.error) {
|
||||
throw Error()
|
||||
}
|
||||
|
||||
appInfo.disabled = currentApp.disabled === undefined ? false : currentApp.disabled
|
||||
|
||||
apps.push(appInfo)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
setAppsList([...apps])
|
||||
setAppList(apps)
|
||||
setLoading(false)
|
||||
selectFirstApp(apps)
|
||||
}
|
||||
|
||||
if (!appsList.length) {
|
||||
if (!appList.length) {
|
||||
loadApps()
|
||||
}
|
||||
}, [appsList])
|
||||
}, [])
|
||||
|
||||
// on iframe change
|
||||
useEffect(() => {
|
||||
|
@ -191,58 +358,54 @@ function Apps({
|
|||
}
|
||||
}, [iframeEl])
|
||||
|
||||
const onSelectApp = (appId) => {
|
||||
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) {
|
||||
if (loading) {
|
||||
return <Loader />
|
||||
}
|
||||
|
||||
return (
|
||||
<LCL.Wrapper>
|
||||
<LCL.Nav>
|
||||
<ButtonLink onClick={() => {}} size="lg" testId="manage-tokens-btn">
|
||||
Manage Apps
|
||||
</ButtonLink>
|
||||
</LCL.Nav>
|
||||
<LCL.Menu>
|
||||
<LCL.List activeItem={selectedApp} items={appsList} onItemClick={onSelectApp} />
|
||||
</LCL.Menu>
|
||||
<LCL.Content>{getContent()}</LCL.Content>
|
||||
<LCL.Footer>
|
||||
This App is provided by{' '}
|
||||
<ButtonLink
|
||||
onClick={() => window.open(getSelectedApp().providedBy.url, '_blank')}
|
||||
size="lg"
|
||||
testId="manage-tokens-btn"
|
||||
>
|
||||
{selectedApp && getSelectedApp().providedBy.name}
|
||||
</ButtonLink>
|
||||
</LCL.Footer>
|
||||
</LCL.Wrapper>
|
||||
<>
|
||||
<Menu>
|
||||
<ManageApps appList={appList} onAppAdded={onAppAdded} onAppToggle={onAppToggle} />
|
||||
</Menu>
|
||||
{getEnabledApps().length ? (
|
||||
<LCL.Wrapper>
|
||||
<LCL.Menu>
|
||||
<LCL.List activeItem={selectedApp} items={getEnabledApps()} onItemClick={onSelectApp} />
|
||||
</LCL.Menu>
|
||||
<LCL.Content>{getContent()}</LCL.Content>
|
||||
{/* <LCL.Footer>
|
||||
{getSelectedApp() && getSelectedApp().providedBy && (
|
||||
<>
|
||||
<p>This App is provided by </p>
|
||||
<ButtonLink
|
||||
onClick={() => window.open(getSelectedApp().providedBy.url, '_blank')}
|
||||
size="lg"
|
||||
testId="manage-tokens-btn"
|
||||
>
|
||||
{selectedApp && getSelectedApp().providedBy.name}
|
||||
</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'
|
||||
|
||||
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) => {
|
||||
try {
|
||||
return JSON.parse(origin)
|
||||
} catch (error) {
|
||||
console.error(`Impossible to parse TX origin: ${origin}`)
|
||||
console.error(`Impossible to parse TX from origin: ${origin}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const appInfo = await axios.get(`${appUrl}/manifest.json`)
|
||||
const res = { url: appUrl, ...appInfo.data, iconUrl: appsIconSvg }
|
||||
const appInfo = await axios.get(`${cleanedUpAppUrl}/manifest.json`)
|
||||
|
||||
// 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) {
|
||||
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'])) {
|
||||
res.iconUrl = `${appUrl}/${appInfo.data.iconPath}`
|
||||
res.iconUrl = `${cleanedUpAppUrl}/${appInfo.data.iconPath}`
|
||||
}
|
||||
} 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
|
||||
} catch (error) {
|
||||
console.error(`It was not possible to fetch app from ${appUrl}`)
|
||||
return null
|
||||
console.error(`It was not possible to fetch app from ${cleanedUpAppUrl}: ${error.message}`)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
|
|
@ -186,6 +186,7 @@ const Layout = (props: Props) => {
|
|||
closeModal={closeGenericModal}
|
||||
createTransaction={createTransaction}
|
||||
ethBalance={ethBalance}
|
||||
granted={granted}
|
||||
network={network}
|
||||
openModal={openGenericModal}
|
||||
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
|
||||
exact
|
||||
path={`${match.path}/settings`}
|
||||
|
|
|
@ -127,11 +127,6 @@ const createTransaction = ({
|
|||
|
||||
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 (process.env.NODE_ENV === 'test') {
|
||||
sendParams.gas = '7000000'
|
||||
|
|
|
@ -110,11 +110,6 @@ const processTransaction = ({
|
|||
|
||||
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 (process.env.NODE_ENV === 'test') {
|
||||
sendParams.gas = '7000000'
|
||||
|
|
Loading…
Reference in New Issue