diff --git a/package.json b/package.json index 1355027dbf..7d344ad473 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@gnosis.pm/safe-deployments": "^1.15.0", "@gnosis.pm/safe-modules-deployments": "^1.0.0", "@gnosis.pm/safe-react-components": "^1.1.2", - "@gnosis.pm/safe-react-gateway-sdk": "^3.0.1", + "@gnosis.pm/safe-react-gateway-sdk": "git+https://github.com/safe-global/safe-react-gateway-sdk#delegates", "@gnosis.pm/safe-web3-lib": "^1.0.0", "@material-ui/core": "^4.12.3", "@material-ui/icons": "^4.11.0", diff --git a/src/components/AppLayout/Sidebar/useSidebarItems.tsx b/src/components/AppLayout/Sidebar/useSidebarItems.tsx index 5e5b10c3a9..a82749eefb 100644 --- a/src/components/AppLayout/Sidebar/useSidebarItems.tsx +++ b/src/components/AppLayout/Sidebar/useSidebarItems.tsx @@ -106,6 +106,11 @@ const useSidebarItems = (): ListItemType[] => { iconType: 'settingsTool', href: currentSafeRoutes.SETTINGS_ADVANCED, }), + makeEntryItem({ + label: 'Delegates', + iconType: 'settingsTool', + href: currentSafeRoutes.SETTINGS_DELEGATES, + }), ].filter(Boolean) return [ diff --git a/src/config/index.ts b/src/config/index.ts index f6baef91ed..8555c8eff7 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -40,8 +40,7 @@ export const _getChainId = (): ChainId => { return _chainId } -export const isValidChainId = (chainId: unknown): chainId is ChainId => - getChains().some((chain) => chain.chainId === chainId) +export const isValidChainId = (chainId: unknown): boolean => getChains().some((chain) => chain.chainId === chainId) export const getChainById = (chainId: ChainId): ChainInfo => { return getChains().find((chain) => chain.chainId === chainId) || emptyChainInfo diff --git a/src/logic/delegates/api/delegates.ts b/src/logic/delegates/api/delegates.ts new file mode 100644 index 0000000000..83c3831413 --- /dev/null +++ b/src/logic/delegates/api/delegates.ts @@ -0,0 +1,10 @@ +import { getDelegates } from '@gnosis.pm/safe-react-gateway-sdk' +import { DelegateResponse, DelegatesRequest } from '@gnosis.pm/safe-react-gateway-sdk/dist/types/delegates' + +export const fetchDelegates = async ( + chainId: string, + query?: DelegatesRequest | undefined, +): Promise => { + const res = await getDelegates(chainId, query) + return res +} diff --git a/src/routes/routes.ts b/src/routes/routes.ts index e55c8149bd..353f30942c 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -66,6 +66,7 @@ export const SAFE_ROUTES = { SETTINGS_POLICIES: `${ADDRESSED_ROUTE}/settings/policies`, SETTINGS_SPENDING_LIMIT: `${ADDRESSED_ROUTE}/settings/spending-limit`, SETTINGS_ADVANCED: `${ADDRESSED_ROUTE}/settings/advanced`, + SETTINGS_DELEGATES: `${ADDRESSED_ROUTE}/settings/delegates`, } export const getNetworkRootRoutes = (): Array<{ chainId: ChainId; route: string; shortName: string }> => diff --git a/src/routes/safe/components/Settings/Delegates/AddDelegateModal/index.tsx b/src/routes/safe/components/Settings/Delegates/AddDelegateModal/index.tsx new file mode 100644 index 0000000000..32221969aa --- /dev/null +++ b/src/routes/safe/components/Settings/Delegates/AddDelegateModal/index.tsx @@ -0,0 +1,95 @@ +import { ReactElement } from 'react' + +import { Modal } from 'src/components/Modal' +import Field from 'src/components/forms/Field' +import GnoForm from 'src/components/forms/GnoForm' +import TextField from 'src/components/forms/TextField' +import Block from 'src/components/layout/Block' +import Col from 'src/components/layout/Col' +import Row from 'src/components/layout/Row' +import { composeValidators, mustBeAddressHash, required } from 'src/components/forms/validator' + +import { useStyles } from './style' + +type DelegateEntry = { + address: string + label: string +} + +const formMutators = { + setDelegateAddress: (args, state, utils) => { + utils.changeValue(state, 'address', () => args[0]) + }, + setDelegateLabel: (args, state, utils) => { + utils.changeValue(state, 'label', () => args[0]) + }, +} + +type AddDelegateModalProps = { + isOpen: boolean + onClose: () => void + onSubmit: (entry: DelegateEntry) => void +} + +export const AddDelegateModal = ({ isOpen, onClose, onSubmit }: AddDelegateModalProps): ReactElement => { + const classes = useStyles() + + const onFormSubmitted = (values: DelegateEntry) => { + onSubmit(values) + } + + return ( + + + {'Add a delegate'} + + + + {(...args) => { + const formState = args[2] + + return ( + <> + + + + + + + + + + + + + + + + + ) + }} + + + + ) +} diff --git a/src/routes/safe/components/Settings/Delegates/AddDelegateModal/style.ts b/src/routes/safe/components/Settings/Delegates/AddDelegateModal/style.ts new file mode 100644 index 0000000000..5511eedcec --- /dev/null +++ b/src/routes/safe/components/Settings/Delegates/AddDelegateModal/style.ts @@ -0,0 +1,24 @@ +import { createStyles, makeStyles } from '@material-ui/core/styles' + +import { lg, md } from 'src/theme/variables' + +export const useStyles = makeStyles( + createStyles({ + heading: { + padding: lg, + justifyContent: 'space-between', + boxSizing: 'border-box', + height: '74px', + }, + manage: { + fontSize: lg, + }, + container: { + padding: `${md} ${lg}`, + }, + close: { + height: '35px', + width: '35px', + }, + }), +) diff --git a/src/routes/safe/components/Settings/Delegates/EditDelegateModal/index.tsx b/src/routes/safe/components/Settings/Delegates/EditDelegateModal/index.tsx new file mode 100644 index 0000000000..b8c027e08a --- /dev/null +++ b/src/routes/safe/components/Settings/Delegates/EditDelegateModal/index.tsx @@ -0,0 +1,75 @@ +import { ReactElement } from 'react' + +import { getExplorerInfo } from 'src/config' +import Field from 'src/components/forms/Field' +import GnoForm from 'src/components/forms/GnoForm' +import TextField from 'src/components/forms/TextField' +import { composeValidators, required, validAddressBookName } from 'src/components/forms/validator' +import Block from 'src/components/layout/Block' +import Hairline from 'src/components/layout/Hairline' +import Row from 'src/components/layout/Row' +import Modal, { Modal as GenericModal } from 'src/components/Modal' +import PrefixedEthHashInfo from 'src/components/PrefixedEthHashInfo' +import { ModalHeader } from 'src/routes/safe/components/Balances/SendModal/screens/ModalHeader' + +import { useStyles } from './style' + +type EditDelegateModalProps = { + isOpen: boolean + onClose: () => void + delegate: string + onSubmit: (label: string) => void +} + +export const EditDelegateModal = ({ isOpen, onClose, delegate, onSubmit }: EditDelegateModalProps): ReactElement => { + const classes = useStyles() + + const handleSubmit = ({ label }: { label: string }): void => { + onSubmit(label) + onClose() + } + + return ( + + + + + {(...args) => { + const pristine = args[2].pristine + return ( + <> + + + + + + + + + + + + + + + ) + }} + + + ) +} diff --git a/src/routes/safe/components/Settings/Delegates/EditDelegateModal/style.ts b/src/routes/safe/components/Settings/Delegates/EditDelegateModal/style.ts new file mode 100644 index 0000000000..5b50e7e869 --- /dev/null +++ b/src/routes/safe/components/Settings/Delegates/EditDelegateModal/style.ts @@ -0,0 +1,25 @@ +import { createStyles, makeStyles } from '@material-ui/core' + +import { lg, md } from 'src/theme/variables' + +export const useStyles = makeStyles( + createStyles({ + heading: { + padding: lg, + justifyContent: 'space-between', + boxSizing: 'border-box', + height: '74px', + }, + manage: { + fontSize: lg, + }, + container: { + padding: `${md} ${lg}`, + minHeight: '200px', + }, + close: { + height: '35px', + width: '35px', + }, + }), +) diff --git a/src/routes/safe/components/Settings/Delegates/RemoveDelegateModal/index.tsx b/src/routes/safe/components/Settings/Delegates/RemoveDelegateModal/index.tsx new file mode 100644 index 0000000000..e95b815412 --- /dev/null +++ b/src/routes/safe/components/Settings/Delegates/RemoveDelegateModal/index.tsx @@ -0,0 +1,52 @@ +import { Text } from '@gnosis.pm/safe-react-components' +import { ReactElement } from 'react' + +import { Modal } from 'src/components/Modal' +import GnoForm from 'src/components/forms/GnoForm' + +interface RemoveDelegateModalProps { + delegateToDelete: string + isOpen: boolean + onClose: () => void + onSubmit: (address: string) => void +} + +export const RemoveDelegateModal = ({ + delegateToDelete, + isOpen, + onClose, + onSubmit, +}: RemoveDelegateModalProps): ReactElement => { + const handleDeleteEntrySubmit = () => { + onSubmit(delegateToDelete) + } + + return ( + + + Remove delegate + + + {() => ( + <> + + + This action will remove{' '} + + {delegateToDelete} + {' '} + from the Safe delegates list. + + + + + + + )} + + + ) +} diff --git a/src/routes/safe/components/Settings/Delegates/RemoveDelegateModal/style.ts b/src/routes/safe/components/Settings/Delegates/RemoveDelegateModal/style.ts new file mode 100644 index 0000000000..5511eedcec --- /dev/null +++ b/src/routes/safe/components/Settings/Delegates/RemoveDelegateModal/style.ts @@ -0,0 +1,24 @@ +import { createStyles, makeStyles } from '@material-ui/core/styles' + +import { lg, md } from 'src/theme/variables' + +export const useStyles = makeStyles( + createStyles({ + heading: { + padding: lg, + justifyContent: 'space-between', + boxSizing: 'border-box', + height: '74px', + }, + manage: { + fontSize: lg, + }, + container: { + padding: `${md} ${lg}`, + }, + close: { + height: '35px', + width: '35px', + }, + }), +) diff --git a/src/routes/safe/components/Settings/Delegates/columns.ts b/src/routes/safe/components/Settings/Delegates/columns.ts new file mode 100644 index 0000000000..5e5029d324 --- /dev/null +++ b/src/routes/safe/components/Settings/Delegates/columns.ts @@ -0,0 +1,52 @@ +import { TableCellProps } from '@material-ui/core/TableCell/TableCell' + +export const DELEGATE_ADDRESS_ID = 'delegate' +export const DELEGATOR_ADDRESS_ID = 'delegator' +export const DELEGATE_LABEL_ID = 'label' +export const ACTIONS_ID = 'actions' +export const EDIT_DELEGATE_BUTTON = 'edit-entry-btn' +export const REMOVE_DELEGATE_BUTTON = 'remove-entry-btn' + +type DelegatesTableColumn = { + id: string + label: string + width?: number + custom?: boolean + align?: TableCellProps['align'] +} + +export const generateColumns = (): Array => { + const delegateColumn = { + id: DELEGATE_ADDRESS_ID, + label: 'Delegate', + width: 170, + custom: false, + align: 'left', + static: true, + } + + const delegatorColumn = { + id: DELEGATOR_ADDRESS_ID, + label: 'Delegator', + width: 170, + custom: false, + align: 'left', + static: true, + } + + const labelColumn = { + id: DELEGATE_LABEL_ID, + label: 'Label', + custom: false, + static: true, + } + + const actionsColumn = { + id: ACTIONS_ID, + label: '', + custom: true, + static: true, + } + + return [delegateColumn, delegatorColumn, labelColumn, actionsColumn] +} diff --git a/src/routes/safe/components/Settings/Delegates/index.tsx b/src/routes/safe/components/Settings/Delegates/index.tsx new file mode 100644 index 0000000000..3e8dea8836 --- /dev/null +++ b/src/routes/safe/components/Settings/Delegates/index.tsx @@ -0,0 +1,264 @@ +import { ReactElement, useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import styled from 'styled-components' +import { makeStyles, TableCell, TableContainer, TableRow } from '@material-ui/core' +import { ButtonLink, Icon } from '@gnosis.pm/safe-react-components' +import { keccak256, fromAscii } from 'web3-utils' +import cn from 'classnames' + +import Block from 'src/components/layout/Block' +import Heading from 'src/components/layout/Heading' +import Paragraph from 'src/components/layout/Paragraph/index' +import { lg } from 'src/theme/variables' +import { currentSafeWithNames } from 'src/logic/safe/store/selectors' +import { getChainInfo, getExplorerInfo } from 'src/config' +import { checksumAddress } from 'src/utils/checksumAddress' +import { getWeb3 } from 'src/logic/wallets/getWeb3' +import { userAccountSelector } from 'src/logic/wallets/store/selectors' +import Table from 'src/components/Table' +import { cellWidth } from 'src/components/Table/TableHead' +import { DELEGATE_ADDRESS_ID, DELEGATOR_ADDRESS_ID, generateColumns } from './columns' +import { styles } from './style' +import PrefixedEthHashInfo from 'src/components/PrefixedEthHashInfo' +import Row from 'src/components/layout/Row' +import ButtonHelper from 'src/components/ButtonHelper' +import { AddDelegateModal } from 'src/routes/safe/components/Settings/Delegates/AddDelegateModal' +import { RemoveDelegateModal } from 'src/routes/safe/components/Settings/Delegates/RemoveDelegateModal' +import { EditDelegateModal } from 'src/routes/safe/components/Settings/Delegates/EditDelegateModal' +import { grantedSelector } from 'src/routes/safe/container/selector' +import { DelegateResponse } from '@gnosis.pm/safe-react-gateway-sdk/dist/types/delegates' +import { addDelegate, deleteSafeDelegate } from '@gnosis.pm/safe-react-gateway-sdk' +import { currentChainId } from 'src/logic/config/store/selectors' +import { fetchDelegates } from 'src/logic/delegates/api/delegates' + +const StyledBlock = styled(Block)` + minheight: 420px; + padding: ${lg}; +` + +const StyledHeading = styled(Heading)` + padding-bottom: 0; +` + +const StyledButtonLink = styled(ButtonLink)<{ isDisabled: boolean }>` + display: ${({ isDisabled }) => (isDisabled ? 'none' : 'flex')}; +` + +const useStyles = makeStyles(styles) + +const Delegates = (): ReactElement => { + const { address: safeAddress } = useSelector(currentSafeWithNames) + const userAccount = useSelector(userAccountSelector) + const { transactionService } = getChainInfo() + const [delegatesList, setDelegatesList] = useState([]) + const [addDelegateModalOpen, setAddDelegateModalOpen] = useState(false) + const [editDelegateModalOpen, setEditDelegateModalOpen] = useState(false) + const [delegateToEdit, setDelegateToEdit] = useState('') + const [removeDelegateModalOpen, setRemoveDelegateModalOpen] = useState(false) + const [addressToRemove, setAddressToRemove] = useState('') + const columns = generateColumns() + const autoColumns = columns.filter(({ custom }) => !custom) + const granted = useSelector(grantedSelector) + const chainId = useSelector(currentChainId) + + const classes = useStyles(styles) + + const getSignature = async (delegate) => { + const totp = Math.floor(Date.now() / 1000 / 3600) + const msg = checksumAddress(delegate) + totp + const hashMessage = keccak256(fromAscii(msg)) + + const web3 = getWeb3() + const signature = await web3.eth.sign(hashMessage, userAccount) + + return signature + } + + useEffect(() => { + if (!safeAddress || !transactionService) return + fetchDelegates(chainId, { safe: safeAddress }).then((delegates) => { + setDelegatesList(delegates.results) + }) + }, [chainId, safeAddress, transactionService]) + + const handleAddDelegate = async ({ address, label }) => { + // close Add delegate modal + setAddDelegateModalOpen(false) + + const delegate = checksumAddress(address) + const signature = await getSignature(delegate) + + try { + await addDelegate(chainId, { + safe: safeAddress, + delegate, + delegator: userAccount, + signature, + label, + }) + } catch (e) { + console.error(e) + } + + fetchDelegates(chainId, { safe: safeAddress }).then(({ results }) => { + setDelegatesList(results) + }) + } + + const handleEditDelegateLabel = async (label) => { + // close Edit delegate modal + setEditDelegateModalOpen(false) + + const delegate = checksumAddress(delegateToEdit) + const signature = await getSignature(delegate) + + try { + await addDelegate(chainId, { + safe: safeAddress, + delegate, + delegator: userAccount, + signature, + label, + }) + } catch (e) { + console.error(e) + } + + fetchDelegates(chainId, { safe: safeAddress }).then(({ results }) => { + setDelegatesList(results) + }) + } + + const handleRemoveDelegate = async (address: string) => { + // close Remove delegate modal + setRemoveDelegateModalOpen(false) + + const delegate = checksumAddress(address) + const signature = await getSignature(delegate) + + try { + await deleteSafeDelegate(chainId, safeAddress, delegate, { + safe: safeAddress, + delegate, + // delegator: userAccount, + signature, + }) + } catch (e) { + console.error(e) + } + + setAddressToRemove('') + fetchDelegates(chainId, { safe: safeAddress }).then(({ results }) => { + setDelegatesList(results) + }) + } + + return ( + + Manage Safe Delegates + Get, add and delete delegates. + { + setAddDelegateModalOpen(true) + }} + color="primary" + iconType="add" + iconSize="sm" + textSize="xl" + isDisabled={!granted} + > + Add delegate + +
{JSON.stringify(delegatesList, undefined, 2)}
+ + + {(data) => + data.map((row, index) => { + const hideBorderBottom = index >= 3 && index === data.size - 1 && classes.noBorderBottom + return ( + + {autoColumns.map((column) => { + const displayEthHash = [DELEGATE_ADDRESS_ID, DELEGATOR_ADDRESS_ID].includes(column.id) + return ( + + {displayEthHash ? ( + + + + ) : ( + row[column.id] + )} + + ) + })} + + + {granted && ( + <> + { + setDelegateToEdit(row[DELEGATE_ADDRESS_ID]) + setEditDelegateModalOpen(true) + }} + > + + + { + setAddressToRemove(row[DELEGATE_ADDRESS_ID]) + setRemoveDelegateModalOpen(true) + }} + > + + + + )} + + + + ) + }) + } +
+
+ setAddDelegateModalOpen(false)} + onSubmit={handleAddDelegate} + /> + setEditDelegateModalOpen(false)} + onSubmit={handleEditDelegateLabel} + /> + setRemoveDelegateModalOpen(false)} + onSubmit={handleRemoveDelegate} + /> +
+ ) +} + +export default Delegates diff --git a/src/routes/safe/components/Settings/Delegates/style.ts b/src/routes/safe/components/Settings/Delegates/style.ts new file mode 100644 index 0000000000..cac07842dd --- /dev/null +++ b/src/routes/safe/components/Settings/Delegates/style.ts @@ -0,0 +1,55 @@ +import { background, lg, md, sm } from 'src/theme/variables' +import { createStyles } from '@material-ui/core' + +export const styles = createStyles({ + formContainer: { + minHeight: '250px', + }, + title: { + padding: lg, + paddingBottom: 0, + }, + annotation: { + paddingLeft: lg, + }, + hide: { + '&:hover': { + backgroundColor: `${background}`, + }, + '&:hover $actions': { + visibility: 'initial', + }, + }, + actions: { + justifyContent: 'flex-end', + alignItems: 'center', + visibility: 'hidden', + minWidth: '100px', + gap: md, + }, + noBorderBottom: { + '& > td': { + borderBottom: 'none', + }, + }, + controlsRow: { + backgroundColor: 'white', + padding: lg, + borderRadius: sm, + }, + editEntryButton: { + cursor: 'pointer', + }, + removeEntryButton: { + cursor: 'pointer', + }, + removeEntryButtonDisabled: { + cursor: 'default', + }, + leftIcon: { + marginRight: sm, + }, + iconSmall: { + fontSize: 16, + }, +}) diff --git a/src/routes/safe/components/Settings/index.tsx b/src/routes/safe/components/Settings/index.tsx index fbb5cd20d5..54ab3784c6 100644 --- a/src/routes/safe/components/Settings/index.tsx +++ b/src/routes/safe/components/Settings/index.tsx @@ -24,6 +24,7 @@ const RemoveSafeModal = lazy(() => import('./RemoveSafeModal')) const SafeDetails = lazy(() => import('./SafeDetails')) const ThresholdSettings = lazy(() => import('./ThresholdSettings')) const Appearance = lazy(() => import('./Appearance')) +const Delegates = lazy(() => import('./Delegates')) export const OWNERS_SETTINGS_TAB_TEST_ID = 'owner-settings-tab' @@ -123,6 +124,7 @@ const Settings = (): React.ReactElement => { } /> } /> } /> + } /> diff --git a/yarn.lock b/yarn.lock index a12df895c6..66374ee779 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1913,10 +1913,9 @@ dependencies: isomorphic-unfetch "^3.1.0" -"@gnosis.pm/safe-react-gateway-sdk@^3.0.1": +"@gnosis.pm/safe-react-gateway-sdk@git+https://github.com/safe-global/safe-react-gateway-sdk#delegates": version "3.0.1" - resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-react-gateway-sdk/-/safe-react-gateway-sdk-3.0.1.tgz#0588072cf8aa4d83d46d12ff8d94e2ea7d29ad38" - integrity sha512-k4SgNW2VmODHFfd+sRd5Zuh3KVvSyD/EGqms87PnaW5bkerfl49stsW2F1hnO6hMaJDEKcgpzds7UjDHLLZ6OQ== + resolved "git+https://github.com/safe-global/safe-react-gateway-sdk#68023a8bbace5b36568bd1f72559ee07b27ce729" dependencies: cross-fetch "^3.1.5"