From 8919c56385eba733f4a810bd2fd4ba8a0f10d602 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 22 Nov 2023 14:54:14 +0100 Subject: [PATCH] feat: recovery module removal --- .../Recovery/ConfirmRemoveRecoveryModal.tsx | 74 +++++++++++++++++++ .../settings/Recovery/DelayModifierRow.tsx | 62 ++++++++++++++++ src/components/settings/Recovery/index.tsx | 47 ++---------- src/components/settings/SafeModules/index.tsx | 64 ++++++++++------ .../sidebar/SidebarNavigation/index.tsx | 4 +- .../{NewOwnerList => OwnerList}/index.tsx | 17 ++++- .../styles.module.css | 0 .../tx-flow/flows/AddOwner/ReviewOwner.tsx | 4 +- .../RecoverAccountFlowReview.tsx | 4 +- .../RemoveRecoveryFlowOverview.tsx | 51 +++++++++++++ .../RemoveRecoveryFlowReview.tsx | 32 ++++++++ .../tx-flow/flows/RemoveRecovery/index.tsx | 33 +++++++++ src/store/__tests__/recoverySlice.test.ts | 34 ++++++++- src/store/recoverySlice.ts | 7 ++ 14 files changed, 359 insertions(+), 74 deletions(-) create mode 100644 src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx create mode 100644 src/components/settings/Recovery/DelayModifierRow.tsx rename src/components/tx-flow/common/{NewOwnerList => OwnerList}/index.tsx (70%) rename src/components/tx-flow/common/{NewOwnerList => OwnerList}/styles.module.css (100%) create mode 100644 src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx create mode 100644 src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx create mode 100644 src/components/tx-flow/flows/RemoveRecovery/index.tsx diff --git a/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx b/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx new file mode 100644 index 0000000000..aceed86f39 --- /dev/null +++ b/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx @@ -0,0 +1,74 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + SvgIcon, +} from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' +import { useContext } from 'react' +import type { ReactElement } from 'react' + +import AlertIcon from '@/public/images/notifications/alert.svg' +import { TxModalContext } from '@/components/tx-flow' +import { RemoveRecoveryFlow } from '@/components/tx-flow/flows/RemoveRecovery' +import type { RecoveryState } from '@/store/recoverySlice' + +export function ConfirmRemoveRecoveryModal({ + open, + onClose, + delayModifier, +}: { + open: boolean + onClose: () => void + delayModifier: RecoveryState[number] +}): ReactElement { + const { setTxFlow } = useContext(TxModalContext) + + const onConfirm = () => { + setTxFlow() + onClose() + } + + return ( + + + theme.palette.error.main, + mr: '10px', + }} + /> + Remove the recovery module? + theme.palette.text.secondary, + ml: 'auto', + }} + > + + + + + + + Are you sure you wish to remove the recovery module? The assigned guardian won't be able to recover this + Safe account for you. + + + + + + + + + ) +} diff --git a/src/components/settings/Recovery/DelayModifierRow.tsx b/src/components/settings/Recovery/DelayModifierRow.tsx new file mode 100644 index 0000000000..2a653a75eb --- /dev/null +++ b/src/components/settings/Recovery/DelayModifierRow.tsx @@ -0,0 +1,62 @@ +import { IconButton, SvgIcon, Tooltip } from '@mui/material' +import { useContext, useState } from 'react' +import type { ReactElement } from 'react' + +import { TxModalContext } from '@/components/tx-flow' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import DeleteIcon from '@/public/images/common/delete.svg' +import EditIcon from '@/public/images/common/edit.svg' +import CheckWallet from '@/components/common/CheckWallet' +import { ConfirmRemoveRecoveryModal } from './ConfirmRemoveRecoveryModal' +import type { RecoveryState } from '@/store/recoverySlice' + +export function DelayModifierRow({ delayModifier }: { delayModifier: RecoveryState[number] }): ReactElement | null { + const { setTxFlow } = useContext(TxModalContext) + const isOwner = useIsSafeOwner() + const [confirm, setConfirm] = useState(false) + + if (!isOwner) { + return null + } + + const onEdit = () => { + // TODO: Display flow + setTxFlow(undefined) + } + + const onDelete = () => { + setConfirm(true) + } + + const onCloseConfirm = () => { + setConfirm(false) + } + + return ( + <> + + {(isOk) => ( + <> + + + + + + + + + + + + + + + + + )} + + + + + ) +} diff --git a/src/components/settings/Recovery/index.tsx b/src/components/settings/Recovery/index.tsx index 5aa56a805b..ec7eff02ce 100644 --- a/src/components/settings/Recovery/index.tsx +++ b/src/components/settings/Recovery/index.tsx @@ -1,4 +1,4 @@ -import { Alert, Box, Button, Grid, IconButton, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' +import { Alert, Box, Button, Grid, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' import { useContext, useMemo } from 'react' import type { ReactElement } from 'react' @@ -6,15 +6,12 @@ import { EnableRecoveryFlow } from '@/components/tx-flow/flows/EnableRecovery' import { TxModalContext } from '@/components/tx-flow' import { Chip } from '@/components/common/Chip' import ExternalLink from '@/components/common/ExternalLink' -import { RecoverAccountFlow } from '@/components/tx-flow/flows/RecoverAccount' +import { DelayModifierRow } from './DelayModifierRow' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { useAppSelector } from '@/store' import { selectRecovery } from '@/store/recoverySlice' import EthHashInfo from '@/components/common/EthHashInfo' -import DeleteIcon from '@/public/images/common/delete.svg' -import EditIcon from '@/public/images/common/edit.svg' import EnhancedTable from '@/components/common/EnhancedTable' -import CheckWallet from '@/components/common/CheckWallet' import InfoIcon from '@/public/images/notifications/info.svg' import tableCss from '@/components/common/EnhancedTable/styles.module.css' @@ -75,7 +72,9 @@ export function Recovery(): ReactElement { const isOwner = useIsSafeOwner() const rows = useMemo(() => { - return recovery.flatMap(({ guardians, txCooldown, txExpiration }) => { + return recovery.flatMap((delayModifier) => { + const { guardians, txCooldown, txExpiration } = delayModifier + return guardians.map((guardian) => { const DAY_IN_SECONDS = 60 * 60 * 24 @@ -109,31 +108,7 @@ export function Recovery(): ReactElement { sticky: true, content: (
- {isOwner && ( - - {(isOk) => ( - <> - - - {/* TODO: Display flow */} - setTxFlow(undefined)} size="small" disabled={!isOk}> - - - - - - - - {/* TODO: Display flow */} - setTxFlow(undefined)} size="small" disabled={!isOk}> - - - - - - )} - - )} +
), }, @@ -141,7 +116,7 @@ export function Recovery(): ReactElement { } }) }) - }, [recovery, isOwner, setTxFlow]) + }, [recovery]) return ( @@ -175,13 +150,7 @@ export function Recovery(): ReactElement { ) : ( - <> - - {/* TODO: Move to correct location when widget is ready */} - - + )} diff --git a/src/components/settings/SafeModules/index.tsx b/src/components/settings/SafeModules/index.tsx index 3a9d4333b6..3f8a937d32 100644 --- a/src/components/settings/SafeModules/index.tsx +++ b/src/components/settings/SafeModules/index.tsx @@ -6,8 +6,11 @@ import ExternalLink from '@/components/common/ExternalLink' import RemoveModuleFlow from '@/components/tx-flow/flows/RemoveModule' import DeleteIcon from '@/public/images/common/delete.svg' import CheckWallet from '@/components/common/CheckWallet' -import { useContext } from 'react' +import { useContext, useState } from 'react' import { TxModalContext } from '@/components/tx-flow' +import { useAppSelector } from '@/store' +import { selectDelayModifierByAddress } from '@/store/recoverySlice' +import { ConfirmRemoveRecoveryModal } from '../Recovery/ConfirmRemoveRecoveryModal' import css from '../TransactionGuards/styles.module.css' const NoModules = () => { @@ -20,31 +23,44 @@ const NoModules = () => { const ModuleDisplay = ({ moduleAddress, chainId, name }: { moduleAddress: string; chainId: string; name?: string }) => { const { setTxFlow } = useContext(TxModalContext) + const [confirmRemoveRecovery, setConfirmRemoveRecovery] = useState(false) + const delayModifier = useAppSelector((state) => selectDelayModifierByAddress(state, moduleAddress)) + + const onRemove = () => { + if (delayModifier) { + setConfirmRemoveRecovery(true) + } else { + setTxFlow() + } + } return ( - - - - {(isOk) => ( - setTxFlow()} - color="error" - size="small" - disabled={!isOk} - title="Remove module" - > - - - )} - - + <> + + + + {(isOk) => ( + + + + )} + + + {delayModifier && ( + setConfirmRemoveRecovery(false)} + delayModifier={delayModifier} + /> + )} + ) } diff --git a/src/components/sidebar/SidebarNavigation/index.tsx b/src/components/sidebar/SidebarNavigation/index.tsx index 3ab0044252..b08b26428d 100644 --- a/src/components/sidebar/SidebarNavigation/index.tsx +++ b/src/components/sidebar/SidebarNavigation/index.tsx @@ -14,7 +14,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { AppRoutes } from '@/config/routes' import useTxQueue from '@/hooks/useTxQueue' import { useAppSelector } from '@/store' -import { selectAllRecoveryQueues } from '@/store/recoverySlice' +import { selectRecoveryQueues } from '@/store/recoverySlice' const getSubdirectory = (pathname: string): string => { return pathname.split('/')[1] @@ -25,7 +25,7 @@ const Navigation = (): ReactElement => { const { safe } = useSafeInfo() const currentSubdirectory = getSubdirectory(router.pathname) const hasQueuedTxs = Boolean(useTxQueue().page?.results.length) - const hasRecoveryTxs = Boolean(useAppSelector(selectAllRecoveryQueues).length) + const hasRecoveryTxs = Boolean(useAppSelector(selectRecoveryQueues).length) // Indicate whether the current Safe needs an upgrade const setupItem = navItems.find((item) => item.href === AppRoutes.settings.setup) diff --git a/src/components/tx-flow/common/NewOwnerList/index.tsx b/src/components/tx-flow/common/OwnerList/index.tsx similarity index 70% rename from src/components/tx-flow/common/NewOwnerList/index.tsx rename to src/components/tx-flow/common/OwnerList/index.tsx index 2164ba756a..f177b62e67 100644 --- a/src/components/tx-flow/common/NewOwnerList/index.tsx +++ b/src/components/tx-flow/common/OwnerList/index.tsx @@ -1,4 +1,5 @@ import { Paper, Typography, SvgIcon } from '@mui/material' +import type { SxProps } from '@mui/material' import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk' import type { ReactElement } from 'react' @@ -7,14 +8,22 @@ import EthHashInfo from '@/components/common/EthHashInfo' import css from './styles.module.css' -export function NewOwnerList({ newOwners }: { newOwners: Array }): ReactElement { +export function OwnerList({ + title, + owners, + sx, +}: { + owners: Array + title?: string + sx?: SxProps +}): ReactElement { return ( - + - New owner{newOwners.length > 1 ? 's' : ''} + {title ?? `New owner{owners.length > 1 ? 's' : ''}`} - {newOwners.map((newOwner) => ( + {owners.map((newOwner) => ( )} - + Any transaction requires the confirmation of: diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx index 4bd5aaabf6..7f8cc7edc0 100644 --- a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx @@ -16,7 +16,7 @@ import { SafeTxContext } from '../../SafeTxProvider' import CheckWallet from '@/components/common/CheckWallet' import { createMultiSendCallOnlyTx, createTx, dispatchRecoveryProposal } from '@/services/tx/tx-sender' import { RecoverAccountFlowFields } from '.' -import { NewOwnerList } from '../../common/NewOwnerList' +import { OwnerList } from '../../common/OwnerList' import { useAppSelector } from '@/store' import { selectDelayModifierByGuardian } from '@/store/recoverySlice' import useWallet from '@/hooks/wallets/useWallet' @@ -93,7 +93,7 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo {newThreshold !== safe.threshold ? ' and threshold' : ''}. - + diff --git a/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx b/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx new file mode 100644 index 0000000000..486bad458b --- /dev/null +++ b/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowOverview.tsx @@ -0,0 +1,51 @@ +import { Button, CardActions, Divider, Typography } from '@mui/material' +import type { ReactElement } from 'react' + +import EthHashInfo from '@/components/common/EthHashInfo' +import TxCard from '../../common/TxCard' +import type { RecoveryFlowProps } from '.' + +import commonCss from '@/components/tx-flow/common/styles.module.css' + +export function RemoveRecoveryFlowOverview({ + delayModifier, + onSubmit, +}: RecoveryFlowProps & { onSubmit: () => void }): ReactElement { + return ( + + + This transaction will remove the recovery module from your Safe Account. You will no longer be able to recover + your Safe Account. + + + + This guardian will not be able to start the initiate the recovery progress once this transaction is executed. + + +
+ + Removing guardian + + + {delayModifier.guardians.map((guardian) => ( + + ))} +
+ + + + + + +
+ ) +} diff --git a/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx b/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx new file mode 100644 index 0000000000..5cc21a0633 --- /dev/null +++ b/src/components/tx-flow/flows/RemoveRecovery/RemoveRecoveryFlowReview.tsx @@ -0,0 +1,32 @@ +import { Typography } from '@mui/material' +import { useContext, useEffect } from 'react' +import type { ReactElement } from 'react' + +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import { createRemoveModuleTx } from '@/services/tx/tx-sender' +import { OwnerList } from '../../common/OwnerList' +import { SafeTxContext } from '../../SafeTxProvider' +import type { RecoveryFlowProps } from '.' + +export function RemoveRecoveryFlowReview({ delayModifier }: RecoveryFlowProps): ReactElement { + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + + useEffect(() => { + createRemoveModuleTx(delayModifier.address).then(setSafeTx).catch(setSafeTxError) + }, [delayModifier.address, setSafeTx, setSafeTxError]) + + return ( + null}> + + This transaction will remove the recovery module from your Safe Account. You will no longer be able to recover + your Safe Account once this transaction is executed. + + + ({ value: guardian }))} + sx={{ bgcolor: ({ palette }) => `${palette.warning.background} !important` }} + /> + + ) +} diff --git a/src/components/tx-flow/flows/RemoveRecovery/index.tsx b/src/components/tx-flow/flows/RemoveRecovery/index.tsx new file mode 100644 index 0000000000..c173cdc583 --- /dev/null +++ b/src/components/tx-flow/flows/RemoveRecovery/index.tsx @@ -0,0 +1,33 @@ +import type { ReactElement } from 'react' + +import TxLayout from '@/components/tx-flow/common/TxLayout' +import RecoveryPlus from '@/public/images/common/recovery-plus.svg' +import useTxStepper from '../../useTxStepper' +import { RemoveRecoveryFlowOverview } from './RemoveRecoveryFlowOverview' +import { RemoveRecoveryFlowReview } from './RemoveRecoveryFlowReview' +import type { RecoveryState } from '@/store/recoverySlice' + +export type RecoveryFlowProps = { + delayModifier: RecoveryState[number] +} + +export function RemoveRecoveryFlow({ delayModifier }: RecoveryFlowProps): ReactElement { + const { step, nextStep, prevStep } = useTxStepper(undefined) + + const steps = [ + nextStep(undefined)} />, + , + ] + + return ( + + {steps} + + ) +} diff --git a/src/store/__tests__/recoverySlice.test.ts b/src/store/__tests__/recoverySlice.test.ts index bd76576d1b..857c48568b 100644 --- a/src/store/__tests__/recoverySlice.test.ts +++ b/src/store/__tests__/recoverySlice.test.ts @@ -1,7 +1,12 @@ import { BigNumber } from 'ethers' import { faker } from '@faker-js/faker' -import { selectDelayModifierByGuardian, selectRecoveryQueues, selectDelayModifierByTxHash } from '../recoverySlice' +import { + selectDelayModifierByGuardian, + selectRecoveryQueues, + selectDelayModifierByTxHash, + selectDelayModifierByAddress, +} from '../recoverySlice' import type { RecoveryState } from '../recoverySlice' import type { RootState } from '..' @@ -93,4 +98,31 @@ describe('recoverySlice', () => { ).toStrictEqual(delayModifier1) }) }) + + describe('selectDelayModifierByAddress', () => { + it('should return the Delay Modifier for the given txHash', () => { + const delayModifier1 = { + address: faker.finance.ethereumAddress(), + } as unknown as RecoveryState[number] + + const delayModifier2 = { + address: faker.finance.ethereumAddress(), + } as unknown as RecoveryState[number] + + const delayModifier3 = { + address: faker.finance.ethereumAddress(), + } as unknown as RecoveryState[number] + + const data = [delayModifier1, delayModifier2, delayModifier3] + + expect( + selectDelayModifierByAddress( + { + recovery: { data }, + } as unknown as RootState, + delayModifier2.address, + ), + ).toStrictEqual(delayModifier2) + }) + }) }) diff --git a/src/store/recoverySlice.ts b/src/store/recoverySlice.ts index 210e710da7..f6fb92f19c 100644 --- a/src/store/recoverySlice.ts +++ b/src/store/recoverySlice.ts @@ -50,3 +50,10 @@ export const selectDelayModifierByTxHash = createSelector( return recovery.find(({ queue }) => queue.some((item) => item.transactionHash === txHash)) }, ) + +export const selectDelayModifierByAddress = createSelector( + [selectRecovery, (_: RootState, moduleAddress: string) => moduleAddress], + (recovery, moduleAddress) => { + return recovery.find(({ address }) => sameAddress(address, moduleAddress)) + }, +)