diff --git a/public/images/common/propose-recovery.svg b/public/images/common/propose-recovery.svg new file mode 100644 index 0000000000..897a20a7e5 --- /dev/null +++ b/public/images/common/propose-recovery.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/common/PageLayout/index.tsx b/src/components/common/PageLayout/index.tsx index 317150384b..0df0489f86 100644 --- a/src/components/common/PageLayout/index.tsx +++ b/src/components/common/PageLayout/index.tsx @@ -11,6 +11,7 @@ import useDebounce from '@/hooks/useDebounce' import { useRouter } from 'next/router' import { TxModalContext } from '@/components/tx-flow' import BatchSidebar from '@/components/batch/BatchSidebar' +import { RecoveryModal } from '@/components/recovery/RecoveryModal' const isNoSidebarRoute = (pathname: string): boolean => { return [ @@ -60,7 +61,9 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE })} >
- {children} + + {children} +
diff --git a/src/components/dashboard/RecoveryHeader/index.test.tsx b/src/components/dashboard/RecoveryHeader/index.test.tsx new file mode 100644 index 0000000000..fe89e17587 --- /dev/null +++ b/src/components/dashboard/RecoveryHeader/index.test.tsx @@ -0,0 +1,58 @@ +import { BigNumber } from 'ethers' + +import { _RecoveryHeader } from '.' +import { render } from '@/tests/test-utils' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +describe('RecoveryHeader', () => { + it('should not render a widget if the chain does not support recovery', () => { + const { container } = render( + <_RecoveryHeader + isOwner + isGuardian + queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]} + supportsRecovery={false} + />, + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render the in-progress widget if there is a queue for guardians', () => { + const { queryByText } = render( + <_RecoveryHeader + isOwner={false} + isGuardian + queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]} + supportsRecovery + />, + ) + + expect(queryByText('Account recovery in progress')).toBeTruthy() + }) + + it('should render the in-progress widget if there is a queue for owners', () => { + const { queryByText } = render( + <_RecoveryHeader + isOwner + isGuardian={false} + queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]} + supportsRecovery + />, + ) + + expect(queryByText('Account recovery in progress')).toBeTruthy() + }) + + it('should render the proposal widget when there is no queue for guardians', () => { + const { queryByText } = render(<_RecoveryHeader isOwner={false} isGuardian queue={[]} supportsRecovery />) + + expect(queryByText('Recover this Account')).toBeTruthy() + }) + + it('should not render the proposal widget when there is no queue for owners', () => { + const { container } = render(<_RecoveryHeader isOwner isGuardian={false} queue={[]} supportsRecovery />) + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/src/components/dashboard/RecoveryHeader/index.tsx b/src/components/dashboard/RecoveryHeader/index.tsx new file mode 100644 index 0000000000..533b8aae2a --- /dev/null +++ b/src/components/dashboard/RecoveryHeader/index.tsx @@ -0,0 +1,57 @@ +import { Grid } from '@mui/material' +import type { ReactElement } from 'react' + +import { useRecoveryQueue } from '@/hooks/useRecoveryQueue' +import { useIsGuardian } from '@/hooks/useIsGuardian' +import madProps from '@/utils/mad-props' +import { FEATURES } from '@/utils/chains' +import { useHasFeature } from '@/hooks/useChains' +import { RecoveryProposalCard } from '@/components/recovery/RecoveryCards/RecoveryProposalCard' +import { RecoveryInProgressCard } from '@/components/recovery/RecoveryCards/RecoveryInProgressCard' +import { WidgetContainer, WidgetBody } from '../styled' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +export function _RecoveryHeader({ + isGuardian, + supportsRecovery, + queue, +}: { + isOwner: boolean + isGuardian: boolean + supportsRecovery: boolean + queue: Array +}): ReactElement | null { + const next = queue[0] + + if (!supportsRecovery) { + return null + } + + const modal = next ? ( + + ) : isGuardian ? ( + + ) : null + + if (modal) { + return ( + + + {modal} + + + ) + } + return null +} + +// Appease TypeScript +const _useSupportedRecovery = () => useHasFeature(FEATURES.RECOVERY) + +export const RecoveryHeader = madProps(_RecoveryHeader, { + isOwner: useIsSafeOwner, + isGuardian: useIsGuardian, + supportsRecovery: _useSupportedRecovery, + queue: useRecoveryQueue, +}) diff --git a/src/components/dashboard/RecoveryInProgress/index.test.tsx b/src/components/dashboard/RecoveryInProgress/index.test.tsx deleted file mode 100644 index cc0d8ff299..0000000000 --- a/src/components/dashboard/RecoveryInProgress/index.test.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { render } from '@testing-library/react' -import { BigNumber } from 'ethers' - -import { _RecoveryInProgress } from '.' -import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/store/recoverySlice' - -jest.mock('@/hooks/useRecoveryTxState') - -const mockUseRecoveryTxState = useRecoveryTxState as jest.MockedFunction - -describe('RecoveryInProgress', () => { - beforeEach(() => { - jest.resetAllMocks() - }) - - it('should return null if the chain does not support recovery', () => { - mockUseRecoveryTxState.mockReturnValue({} as any) - - const result = render( - <_RecoveryInProgress - supportsRecovery={false} - timestamp={0} - queuedTxs={[{ timestamp: BigNumber.from(0) } as RecoveryQueueItem]} - />, - ) - - expect(result.container).toBeEmptyDOMElement() - }) - - it('should return null if there are no delayed transactions', () => { - mockUseRecoveryTxState.mockReturnValue({} as any) - - const result = render(<_RecoveryInProgress supportsRecovery={true} timestamp={69420} queuedTxs={[]} />) - - expect(result.container).toBeEmptyDOMElement() - }) - - it('should return null if all the delayed transactions are expired and invalid', () => { - mockUseRecoveryTxState.mockReturnValue({} as any) - - const result = render( - <_RecoveryInProgress - supportsRecovery={true} - timestamp={69420} - queuedTxs={[ - { - timestamp: BigNumber.from(0), - validFrom: BigNumber.from(69), - expiresAt: BigNumber.from(420), - } as RecoveryQueueItem, - ]} - />, - ) - - expect(result.container).toBeEmptyDOMElement() - }) - - it('should return the countdown of the next non-expired/invalid transactions if none are non-expired/valid', () => { - mockUseRecoveryTxState.mockReturnValue({ - remainingSeconds: 69 * 420 * 1337, - isExecutable: false, - isNext: true, - } as any) - - const mockBlockTimestamp = BigNumber.from(69420) - - const { queryByText } = render( - <_RecoveryInProgress - supportsRecovery={true} - timestamp={mockBlockTimestamp.toNumber()} - queuedTxs={[ - { - timestamp: mockBlockTimestamp.add(1), - validFrom: mockBlockTimestamp.add(1), // Invalid - expiresAt: mockBlockTimestamp.add(1), // Non-expired - } as RecoveryQueueItem, - { - // Older - should render this - timestamp: mockBlockTimestamp, - validFrom: mockBlockTimestamp.mul(4), // Invalid - expiresAt: null, // Non-expired - } as RecoveryQueueItem, - ]} - />, - ) - - expect(queryByText('Account recovery in progress')).toBeInTheDocument() - expect( - queryByText('The recovery process has started. This Account will be ready to recover in:'), - ).toBeInTheDocument() - ;['day', 'hr', 'min'].forEach((unit) => { - // May be pluralised - expect(queryByText(unit, { exact: false })).toBeInTheDocument() - }) - // Days - expect(queryByText('448')).toBeInTheDocument() - // Hours - expect(queryByText('10')).toBeInTheDocument() - // Mins - expect(queryByText('51')).toBeInTheDocument() - }) - - it('should return the info of the next non-expired/valid transaction', () => { - mockUseRecoveryTxState.mockReturnValue({ isExecutable: true, remainingSeconds: 0 } as any) - - const mockBlockTimestamp = BigNumber.from(69420) - - const { queryByText } = render( - <_RecoveryInProgress - supportsRecovery={true} - timestamp={mockBlockTimestamp.toNumber()} - queuedTxs={[ - { - timestamp: mockBlockTimestamp.sub(1), - validFrom: mockBlockTimestamp.sub(1), // Invalid - expiresAt: mockBlockTimestamp.sub(1), // Non-expired - } as RecoveryQueueItem, - { - // Older - should render this - timestamp: mockBlockTimestamp.sub(2), - validFrom: mockBlockTimestamp.sub(1), // Invalid - expiresAt: null, // Non-expired - } as RecoveryQueueItem, - ]} - />, - ) - - expect(queryByText('Account recovery possible')).toBeInTheDocument() - expect(queryByText('The recovery process is possible. This Account can be recovered.')).toBeInTheDocument() - ;['day', 'hr', 'min'].forEach((unit) => { - // May be pluralised - expect(queryByText(unit, { exact: false })).not.toBeInTheDocument() - }) - }) - - it('should return the intemediary info for of the queued, non-expired/valid transactions', () => { - mockUseRecoveryTxState.mockReturnValue({ - isExecutable: false, - isNext: false, - remainingSeconds: 69 * 420 * 1337, - } as any) - - const mockBlockTimestamp = BigNumber.from(69420) - - const { queryByText } = render( - <_RecoveryInProgress - supportsRecovery={true} - timestamp={mockBlockTimestamp.toNumber()} - queuedTxs={[ - { - timestamp: mockBlockTimestamp.sub(1), - validFrom: mockBlockTimestamp.sub(1), // Invalid - expiresAt: mockBlockTimestamp.sub(1), // Non-expired - } as RecoveryQueueItem, - { - // Older - should render this - timestamp: mockBlockTimestamp.sub(2), - validFrom: mockBlockTimestamp.sub(1), // Invalid - expiresAt: null, // Non-expired - } as RecoveryQueueItem, - ]} - />, - ) - - expect(queryByText('Account recovery in progress')).toBeInTheDocument() - expect( - queryByText( - 'The recovery process has started. This Account can be recovered after previous attempts are executed or skipped and the delay period has passed:', - ), - ) - ;['day', 'hr', 'min'].forEach((unit) => { - // May be pluralised - expect(queryByText(unit, { exact: false })).toBeInTheDocument() - }) - // Days - expect(queryByText('448')).toBeInTheDocument() - // Hours - expect(queryByText('10')).toBeInTheDocument() - // Mins - }) -}) diff --git a/src/components/dashboard/RecoveryInProgress/index.tsx b/src/components/dashboard/RecoveryInProgress/index.tsx deleted file mode 100644 index 90972699ec..0000000000 --- a/src/components/dashboard/RecoveryInProgress/index.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Card, Grid, Typography } from '@mui/material' -import type { ReactElement } from 'react' - -import { useAppSelector } from '@/store' -import { useClock } from '@/hooks/useClock' -import { WidgetContainer, WidgetBody } from '../styled' -import RecoveryPending from '@/public/images/common/recovery-pending.svg' -import ExternalLink from '@/components/common/ExternalLink' -import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' -import { selectRecoveryQueues } from '@/store/recoverySlice' -import madProps from '@/utils/mad-props' -import { Countdown } from '@/components/common/Countdown' -import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/store/recoverySlice' - -export function _RecoveryInProgress({ - timestamp, - supportsRecovery, - queuedTxs, -}: { - timestamp: number - supportsRecovery: boolean - queuedTxs: Array -}): ReactElement | null { - const nonExpiredTxs = queuedTxs.filter((queuedTx) => { - return queuedTx.expiresAt ? queuedTx.expiresAt.gt(timestamp) : true - }) - - if (!supportsRecovery || nonExpiredTxs.length === 0) { - return null - } - - // Conditional hook - return <_RecoveryInProgressWidget nextTx={nonExpiredTxs[0]} /> -} - -function _RecoveryInProgressWidget({ nextTx }: { nextTx: RecoveryQueueItem }): ReactElement { - const { isExecutable, isNext, remainingSeconds } = useRecoveryTxState(nextTx) - - // TODO: Migrate `isValid` components when https://github.com/safe-global/safe-wallet-web/issues/2758 is done - return ( - - - - - - - - - - - {isExecutable ? 'Account recovery possible' : 'Account recovery in progress'} - - - {isExecutable - ? 'The recovery process is possible. This Account can be recovered.' - : !isNext - ? remainingSeconds > 0 - ? 'The recovery process has started. This Account can be recovered after previous attempts are executed or skipped and the delay period has passed:' - : 'The recovery process has started. This Account can be recovered after previous attempts are executed or skipped.' - : 'The recovery process has started. This Account will be ready to recover in:'} - - - - - - Learn more - - - - - - - - ) -} - -// Appease React TypeScript warnings -const _useTimestamp = () => useClock(60_000) // Countdown does not display -const _useSupportsRecovery = () => useHasFeature(FEATURES.RECOVERY) -const _useQueuedRecoveryTxs = () => useAppSelector(selectRecoveryQueues) - -export const RecoveryInProgress = madProps(_RecoveryInProgress, { - timestamp: _useTimestamp, - supportsRecovery: _useSupportsRecovery, - queuedTxs: _useQueuedRecoveryTxs, -}) diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 007d9179c7..d51d5f15cb 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -11,7 +11,7 @@ import { Recovery } from './Recovery' import { FEATURES } from '@/utils/chains' import { useHasFeature } from '@/hooks/useChains' import { CREATION_MODAL_QUERY_PARM } from '../new-safe/create/logic' -import { RecoveryInProgress } from './RecoveryInProgress' +import { RecoveryHeader } from './RecoveryHeader' const Dashboard = (): ReactElement => { const router = useRouter() @@ -21,7 +21,7 @@ const Dashboard = (): ReactElement => { return ( <> - + diff --git a/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx b/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx new file mode 100644 index 0000000000..2598a8b222 --- /dev/null +++ b/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx @@ -0,0 +1,104 @@ +import { Button, Card, Divider, Grid, Typography } from '@mui/material' +import { useRouter } from 'next/dist/client/router' +import type { ReactElement } from 'react' + +import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' +import { Countdown } from '@/components/common/Countdown' +import RecoveryPending from '@/public/images/common/recovery-pending.svg' +import ExternalLink from '@/components/common/ExternalLink' +import { AppRoutes } from '@/config/routes' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +import css from './styles.module.css' + +type Props = + | { + orientation?: 'vertical' + onClose: () => void + recovery: RecoveryQueueItem + } + | { + orientation: 'horizontal' + onClose?: never + recovery: RecoveryQueueItem + } + +export function RecoveryInProgressCard({ orientation = 'vertical', onClose, recovery }: Props): ReactElement { + const { isExecutable, remainingSeconds } = useRecoveryTxState(recovery) + const router = useRouter() + + const onClick = async () => { + await router.push({ + pathname: AppRoutes.home, + query: router.query, + }) + onClose?.() + } + + const icon = + const title = isExecutable ? 'Account recovery possible' : 'Account recovery in progress' + const desc = isExecutable + ? 'The recovery process is possible. This Account can be recovered.' + : 'The recovery process has started. This Account will be ready to recover in:' + + const link = ( + + Learn more + + ) + + if (orientation === 'horizontal') { + return ( + + + {icon} + + + + {title} + + + + {desc} + + + + + + {link} + + + ) + } + + return ( + + + + {icon} + + {link} + + + + + {title} + + + {desc} + + + + + + + + + + ) +} diff --git a/src/components/recovery/RecoveryCards/RecoveryProposalCard.tsx b/src/components/recovery/RecoveryCards/RecoveryProposalCard.tsx new file mode 100644 index 0000000000..9ac434f099 --- /dev/null +++ b/src/components/recovery/RecoveryCards/RecoveryProposalCard.tsx @@ -0,0 +1,118 @@ +import { Button, Card, Divider, Grid, Typography } from '@mui/material' +import { useContext } from 'react' +import type { ReactElement } from 'react' + +import ProposeRecovery from '@/public/images/common/propose-recovery.svg' +import ExternalLink from '@/components/common/ExternalLink' +import { RecoverAccountFlow } from '@/components/tx-flow/flows/RecoverAccount' +import useSafeInfo from '@/hooks/useSafeInfo' +import madProps from '@/utils/mad-props' +import { TxModalContext } from '@/components/tx-flow' +import type { TxModalContextType } from '@/components/tx-flow' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import css from './styles.module.css' + +type Props = + | { + orientation?: 'vertical' + onClose: () => void + safe: SafeInfo + setTxFlow: TxModalContextType['setTxFlow'] + } + | { + orientation: 'horizontal' + onClose?: never + safe: SafeInfo + setTxFlow: TxModalContextType['setTxFlow'] + } + +export function _RecoveryProposalCard({ orientation = 'vertical', onClose, safe, setTxFlow }: Props): ReactElement { + const onRecover = async () => { + onClose?.() + setTxFlow() + } + + const icon = + const title = 'Recover this Account' + const desc = `The connect wallet was chosen as a trusted guardian. You can help the owner${ + safe.owners.length > 1 ? 's' : '' + } regain access by updating the owner list.` + + const link = ( + + Learn more + + ) + + const recoveryButton = ( + + ) + + if (orientation === 'horizontal') { + return ( + + + {icon} + + + + {title} + + + + {desc} + + + {link} + + + {recoveryButton} + + + ) + } + + return ( + + + + {icon} + + {link} + + + + + {title} + + + + {desc} + + + + + + + + {recoveryButton} + + + + ) +} + +// Appease TypeScript +const _useSafe = () => useSafeInfo().safe +const _useSetTxFlow = () => useContext(TxModalContext).setTxFlow + +export const RecoveryProposalCard = madProps(_RecoveryProposalCard, { + safe: _useSafe, + setTxFlow: _useSetTxFlow, +}) diff --git a/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx b/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx new file mode 100644 index 0000000000..91b0d95cbe --- /dev/null +++ b/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx @@ -0,0 +1,131 @@ +import { BigNumber } from 'ethers' +import { fireEvent, waitFor } from '@testing-library/react' + +import { render } from '@/tests/test-utils' +import { RecoveryInProgressCard } from '../RecoveryInProgressCard' +import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +jest.mock('@/hooks/useRecoveryTxState') + +const mockUseRecoveryTxState = useRecoveryTxState as jest.MockedFunction + +describe('RecoveryInProgressCard', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('vertical', () => { + it('should render executable recovery state correctly', async () => { + mockUseRecoveryTxState.mockReturnValue({ + isExecutable: true, + remainingSeconds: 0, + } as any) + + const mockClose = jest.fn() + + const { queryByText } = render( + , + ) + + ;['days', 'hrs', 'mins'].forEach((unit) => { + expect(queryByText(unit)).toBeFalsy() + }) + + expect(queryByText('Account recovery possible')).toBeTruthy() + expect(queryByText('Learn more')).toBeTruthy() + + const dashboardButton = queryByText('Go to dashboard') + expect(dashboardButton).toBeTruthy() + + fireEvent.click(dashboardButton!) + + await waitFor(() => { + expect(mockClose).toHaveBeenCalled() + }) + }) + + it('should render non-executable recovery state correctly', async () => { + mockUseRecoveryTxState.mockReturnValue({ + isExecutable: false, + remainingSeconds: 420 * 69 * 1337, + } as any) + + const mockClose = jest.fn() + + const { queryByText } = render( + , + ) + + expect(queryByText('Account recovery in progress')).toBeTruthy() + expect(queryByText('The recovery process has started. This Account will be ready to recover in:')).toBeTruthy() + ;['days', 'hrs', 'mins'].forEach((unit) => { + expect(queryByText(unit)).toBeTruthy() + }) + expect(queryByText('Learn more')).toBeTruthy() + + const dashboardButton = queryByText('Go to dashboard') + expect(dashboardButton).toBeTruthy() + + fireEvent.click(dashboardButton!) + + await waitFor(() => { + expect(mockClose).toHaveBeenCalled() + }) + }) + }) + describe('horizontal', () => { + it('should render executable recovery state correctly', () => { + mockUseRecoveryTxState.mockReturnValue({ + isExecutable: true, + remainingSeconds: 0, + } as any) + + const { queryByText } = render( + , + ) + + ;['days', 'hrs', 'mins'].forEach((unit) => { + expect(queryByText(unit)).toBeFalsy() + }) + expect(queryByText('Go to dashboard')).toBeFalsy() + + expect(queryByText('Account recovery possible')).toBeTruthy() + expect(queryByText('Learn more')).toBeTruthy() + }) + + it('should render non-executable recovery state correctly', () => { + mockUseRecoveryTxState.mockReturnValue({ + isExecutable: false, + remainingSeconds: 420 * 69 * 1337, + } as any) + + const { queryByText } = render( + , + ) + + expect(queryByText('Go to dashboard')).toBeFalsy() + + expect(queryByText('Account recovery in progress')).toBeTruthy() + expect(queryByText('The recovery process has started. This Account will be ready to recover in:')).toBeTruthy() + ;['days', 'hrs', 'mins'].forEach((unit) => { + expect(queryByText(unit)).toBeTruthy() + }) + expect(queryByText('Learn more')).toBeTruthy() + }) + }) +}) diff --git a/src/components/recovery/RecoveryCards/__tests__/RecoveryProposalCard.test.tsx b/src/components/recovery/RecoveryCards/__tests__/RecoveryProposalCard.test.tsx new file mode 100644 index 0000000000..8ca5df4bed --- /dev/null +++ b/src/components/recovery/RecoveryCards/__tests__/RecoveryProposalCard.test.tsx @@ -0,0 +1,66 @@ +import { faker } from '@faker-js/faker' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { fireEvent, render } from '@/tests/test-utils' +import { _RecoveryProposalCard } from '../RecoveryProposalCard' + +describe('RecoveryProposalCard', () => { + describe('vertical', () => { + it('should render correctly', () => { + const mockClose = jest.fn() + const mockSetTxFlow = jest.fn() + + const { queryByText } = render( + <_RecoveryProposalCard + orientation="vertical" + onClose={mockClose} + safe={{ owners: [{ value: faker.finance.ethereumAddress() }] } as SafeInfo} + setTxFlow={mockSetTxFlow} + />, + ) + + expect(queryByText('Recover this Account')).toBeTruthy() + expect( + queryByText( + 'The connect wallet was chosen as a trusted guardian. You can help the owner regain access by updating the owner list.', + ), + ).toBeTruthy() + expect(queryByText('Learn more')).toBeTruthy() + + const recoveryButton = queryByText('Start recovery') + expect(recoveryButton).toBeTruthy() + + fireEvent.click(recoveryButton!) + + expect(mockClose).toHaveBeenCalled() + expect(mockSetTxFlow).toHaveBeenCalled() + }) + }) + describe('horizontal', () => {}) + it('should render correctly', () => { + const mockSetTxFlow = jest.fn() + + const { queryByText } = render( + <_RecoveryProposalCard + orientation="horizontal" + safe={{ owners: [{ value: faker.finance.ethereumAddress() }] } as SafeInfo} + setTxFlow={mockSetTxFlow} + />, + ) + + expect(queryByText('Recover this Account')).toBeTruthy() + expect( + queryByText( + 'The connect wallet was chosen as a trusted guardian. You can help the owner regain access by updating the owner list.', + ), + ).toBeTruthy() + expect(queryByText('Learn more')).toBeTruthy() + + const recoveryButton = queryByText('Start recovery') + expect(recoveryButton).toBeTruthy() + + fireEvent.click(recoveryButton!) + + expect(mockSetTxFlow).toHaveBeenCalled() + }) +}) diff --git a/src/components/recovery/RecoveryCards/styles.module.css b/src/components/recovery/RecoveryCards/styles.module.css new file mode 100644 index 0000000000..6aa366375a --- /dev/null +++ b/src/components/recovery/RecoveryCards/styles.module.css @@ -0,0 +1,8 @@ +.card { + max-width: 576px; + padding: var(--space-4); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/src/components/recovery/RecoveryModal/index.test.tsx b/src/components/recovery/RecoveryModal/index.test.tsx new file mode 100644 index 0000000000..5e8c2a0e95 --- /dev/null +++ b/src/components/recovery/RecoveryModal/index.test.tsx @@ -0,0 +1,252 @@ +import { BigNumber } from 'ethers' +import { faker } from '@faker-js/faker' +import { renderHook } from '@testing-library/react' +import * as router from 'next/router' + +import { render, waitFor } from '@/tests/test-utils' +import { safeInfoBuilder } from '@/tests/builders/safe' +import { connectedWalletBuilder } from '@/tests/builders/wallet' +import * as safeInfo from '@/hooks/useSafeInfo' +import { _useDidDismissProposal } from './index' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +describe('RecoveryModal', () => { + describe('component', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + let _RecoveryModal: typeof import('./index')._RecoveryModal + + beforeEach(() => { + localStorage.clear() + + // Clear cache in between tests + _RecoveryModal = require('./index')._RecoveryModal + }) + + it('should not render the modal if there is a queue but the user is not an owner or guardian', () => { + const wallet = connectedWalletBuilder().build() + const queue = [{ validFrom: BigNumber.from(0) } as RecoveryQueueItem] + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner={false} isGuardian={false} queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should not render the modal if there is no queue and the user is a guardian', () => { + const wallet = connectedWalletBuilder().build() + const queue = [] as Array + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner={false} isGuardian queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should not render the modal if there is no queue and the user is an owner', () => { + const wallet = connectedWalletBuilder().build() + const queue = [] as Array + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner isGuardian={false} queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should render the in-progress modal when there is a queue for guardians', () => { + const wallet = connectedWalletBuilder().build() + const queue = [{ validFrom: BigNumber.from(0) } as RecoveryQueueItem] + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner={false} isGuardian queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Account recovery in progress')).toBeTruthy() + }) + + it('should render the in-progress modal when there is a queue for owners', () => { + const wallet = connectedWalletBuilder().build() + const queue = [{ validFrom: BigNumber.from(0) } as RecoveryQueueItem] + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner isGuardian={false} queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Account recovery in progress')).toBeTruthy() + }) + + it('should render the proposal modal when there is no queue for guardians', () => { + const wallet = connectedWalletBuilder().build() + const queue = [] as Array + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner={false} isGuardian queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Recover this Account')).toBeTruthy() + }) + + it('should not render the proposal modal when there is no queue for owners', () => { + const wallet = connectedWalletBuilder().build() + const queue = [] as Array + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner isGuardian={false} queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Recover this Account')).toBeFalsy() + }) + + it('should close the modal when the user navigates away', async () => { + const mockUseRouter = { + push: jest.fn(), + query: {}, + events: { + on: jest.fn(), + off: jest.fn(), + }, + } + + jest.spyOn(router, 'useRouter').mockReturnValue(mockUseRouter as any) + + const wallet = connectedWalletBuilder().build() + const queue = [{ validFrom: BigNumber.from(0), transactionHash: faker.string.hexadecimal() } as RecoveryQueueItem] + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner isGuardian={false} queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Account recovery in progress')).toBeTruthy() + + // Trigger the route change + mockUseRouter.events.on.mock.calls[0][1]() + + await waitFor(() => { + expect(queryByText('Account recovery in progress')).toBeFalsy() + }) + }) + }) + + describe('hooks', () => { + beforeEach(() => { + localStorage.clear() + + const safe = safeInfoBuilder().build() + jest + .spyOn(safeInfo, 'default') + .mockReturnValue({ safe, safeAddress: safe.address.value } as ReturnType) + }) + + describe('useDidDismissProposal', () => { + it('should return false if the proposal was not dismissed before', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const { result } = renderHook(() => _useDidDismissProposal()) + + expect(result.current.wasProposalDismissed(guardianAddress)).toBeFalsy() + }) + + it('should return true if the proposal was dismissed before', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const { result, rerender } = renderHook(() => _useDidDismissProposal()) + + expect(result.current.wasProposalDismissed(guardianAddress)).toBeFalsy() + result.current.dismissProposal(guardianAddress) + + rerender() + + expect(result.current.wasProposalDismissed(guardianAddress)).toBeTruthy() + }) + + it('should persist dismissals between sessions', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const firstRender = renderHook(() => _useDidDismissProposal()) + + expect(firstRender.result.current.wasProposalDismissed(guardianAddress)).toBeFalsy() + firstRender.result.current.dismissProposal(guardianAddress) + + firstRender.rerender() + + expect(firstRender.result.current.wasProposalDismissed(guardianAddress)).toBeTruthy() + + firstRender.unmount() + + const secondRender = renderHook(() => _useDidDismissProposal()) + expect(secondRender.result.current.wasProposalDismissed(guardianAddress)).toBeTruthy() + }) + }) + + describe('useDidDismissInProgress', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + let _useDidDismissInProgress: typeof import('./index')._useDidDismissInProgress + + beforeEach(() => { + localStorage.clear() + + // Clear cache in between tests + _useDidDismissInProgress = require('./index')._useDidDismissInProgress + }) + + it('should return false if in-progress was not dismissed before', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const { result } = renderHook(() => _useDidDismissInProgress()) + + expect(result.current.wasInProgressDismissed(guardianAddress)).toBeFalsy() + }) + + it('should return true if in-progress was not dismissed before', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const { result } = renderHook(() => _useDidDismissInProgress()) + + expect(result.current.wasInProgressDismissed(guardianAddress)).toBeFalsy() + result.current.dismissInProgress(guardianAddress) + expect(result.current.wasInProgressDismissed(guardianAddress)).toBeTruthy() + }) + + it('should not persist dismissals between sessions', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const firstRender = renderHook(() => _useDidDismissInProgress()) + + expect(firstRender.result.current.wasInProgressDismissed(guardianAddress)).toBeFalsy() + firstRender.result.current.dismissInProgress(guardianAddress) + expect(firstRender.result.current.wasInProgressDismissed(guardianAddress)).toBeTruthy() + + firstRender.unmount() + + const secondRender = renderHook(() => _useDidDismissInProgress()) + expect(secondRender.result.current.wasInProgressDismissed(guardianAddress)).toBeFalsy() + }) + }) + }) +}) diff --git a/src/components/recovery/RecoveryModal/index.tsx b/src/components/recovery/RecoveryModal/index.tsx new file mode 100644 index 0000000000..703865d947 --- /dev/null +++ b/src/components/recovery/RecoveryModal/index.tsx @@ -0,0 +1,178 @@ +import { Backdrop, Fade } from '@mui/material' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/router' +import type { ReactElement, ReactNode } from 'react' + +import { useRecoveryQueue } from '@/hooks/useRecoveryQueue' +import { RecoveryInProgressCard } from '../RecoveryCards/RecoveryInProgressCard' +import { RecoveryProposalCard } from '../RecoveryCards/RecoveryProposalCard' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { useIsGuardian } from '@/hooks/useIsGuardian' +import madProps from '@/utils/mad-props' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import useWallet from '@/hooks/wallets/useWallet' +import useSafeInfo from '@/hooks/useSafeInfo' +import { sameAddress } from '@/utils/addresses' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +export function _RecoveryModal({ + children, + isOwner, + isGuardian, + queue, + wallet, +}: { + children: ReactNode + isOwner: boolean + isGuardian: boolean + queue: Array + wallet: ReturnType +}): ReactElement { + const { wasProposalDismissed, dismissProposal } = _useDidDismissProposal() + const { wasInProgressDismissed, dismissInProgress } = _useDidDismissInProgress() + + const [modal, setModal] = useState(null) + const router = useRouter() + + const next = queue[0] + + // Close modal + const onClose = () => { + setModal(null) + } + + // Trigger modal + useEffect(() => { + setModal(() => { + if (next && !wasInProgressDismissed(next.transactionHash)) { + const onCloseWithDismiss = () => { + dismissInProgress(next.transactionHash) + onClose() + } + + return + } + + if (wallet?.address && !isOwner && !wasProposalDismissed(wallet.address)) { + const onCloseWithDismiss = () => { + dismissProposal(wallet.address) + onClose() + } + + return + } + + return null + }) + }, [ + dismissInProgress, + dismissProposal, + isGuardian, + isOwner, + next, + queue.length, + wallet, + wasInProgressDismissed, + wasProposalDismissed, + ]) + + // Close modal on navigation + useEffect(() => { + router.events.on('routeChangeComplete', onClose) + return () => { + router.events.off('routeChangeComplete', onClose) + } + }, [router]) + + return ( + <> + + palette.background.main }}> + {modal} + + + {children} + + ) +} + +export const RecoveryModal = madProps(_RecoveryModal, { + isOwner: useIsSafeOwner, + isGuardian: useIsGuardian, + queue: useRecoveryQueue, + wallet: useWallet, +}) + +export function _useDidDismissProposal() { + const LS_KEY = 'dismissedRecoveryProposals' + + type Guardian = string + type DismissedProposalCache = { [chainId: string]: { [safeAddress: string]: Guardian } } + + const { safe, safeAddress } = useSafeInfo() + const chainId = safe.chainId + + const [dismissedProposals, setDismissedProposals] = useLocalStorage(LS_KEY) + + // Cache dismissal of proposal modal + const dismissProposal = useCallback( + (guardianAddress: string) => { + const dismissed = dismissedProposals?.[chainId] ?? {} + + setDismissedProposals({ + ...(dismissedProposals ?? {}), + [chainId]: { + ...dismissed, + [safeAddress]: guardianAddress, + }, + }) + }, + [dismissedProposals, chainId, safeAddress, setDismissedProposals], + ) + + const wasProposalDismissed = useCallback( + (guardianAddress: string) => { + // If no proposals, is guardian and didn't ever dismiss + return sameAddress(dismissedProposals?.[chainId]?.[safeAddress], guardianAddress) + }, + [chainId, dismissedProposals, safeAddress], + ) + + return { wasProposalDismissed, dismissProposal } +} + +export function _useDidDismissInProgress() { + type TxHash = string + type DismissedInProgressCache = { [chainId: string]: { [safeAddress: string]: TxHash } } + + const { safe, safeAddress } = useSafeInfo() + const chainId = safe.chainId + + const dismissedInProgress = useRef({}) + + // Cache dismissal of in-progress modal + const dismissInProgress = useCallback( + (txHash: string) => { + const dismissed = dismissedInProgress.current?.[chainId] ?? {} + + dismissedInProgress.current = { + ...dismissedInProgress.current, + [chainId]: { + ...dismissed, + [safeAddress]: txHash, + }, + } + }, + [chainId, safeAddress], + ) + + const wasInProgressDismissed = useCallback( + (txHash: string) => { + // If proposal and did not notify during current session of Safe + return sameAddress(txHash, dismissedInProgress.current?.[chainId]?.[safeAddress]) + }, + [chainId, safeAddress], + ) + + return { wasInProgressDismissed, dismissInProgress } +} diff --git a/src/components/settings/Recovery/index.tsx b/src/components/settings/Recovery/index.tsx index 5aa56a805b..38fd83f4a7 100644 --- a/src/components/settings/Recovery/index.tsx +++ b/src/components/settings/Recovery/index.tsx @@ -6,7 +6,6 @@ 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 useIsSafeOwner from '@/hooks/useIsSafeOwner' import { useAppSelector } from '@/store' import { selectRecovery } from '@/store/recoverySlice' @@ -175,13 +174,7 @@ export function Recovery(): ReactElement { ) : ( - <> - - {/* TODO: Move to correct location when widget is ready */} - - + )} 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/flows/EnableRecovery/EnableRecoveryFlowReview.tsx b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx index 0b5322c5c9..6b9d75e9b3 100644 --- a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx +++ b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx @@ -53,7 +53,7 @@ export function EnableRecoveryFlowReview({ params }: { params: EnableRecoveryFlo null}> This transaction will enable the Account recovery feature once executed. - + diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx index a11d7ba3d7..b51ab5f7a8 100644 --- a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx @@ -11,6 +11,7 @@ import { Tooltip, } from '@mui/material' import { useForm, FormProvider, useFieldArray, Controller } from 'react-hook-form' +import { Fragment } from 'react' import type { ReactElement } from 'react' import TxCard from '../../common/TxCard' @@ -64,7 +65,7 @@ export function RecoverAccountFlowSetup({ {fields.map((field, index) => ( - <> + )} - + ))} diff --git a/src/hooks/useIsGuardian.ts b/src/hooks/useIsGuardian.ts new file mode 100644 index 0000000000..eb2ae9eb4e --- /dev/null +++ b/src/hooks/useIsGuardian.ts @@ -0,0 +1,8 @@ +import { useAppSelector } from '@/store' +import { selectDelayModifierByGuardian } from '@/store/recoverySlice' +import useWallet from './wallets/useWallet' + +export function useIsGuardian() { + const wallet = useWallet() + return !!useAppSelector((state) => selectDelayModifierByGuardian(state, wallet?.address ?? '')) +} diff --git a/src/hooks/useRecoveryQueue.ts b/src/hooks/useRecoveryQueue.ts new file mode 100644 index 0000000000..69bf04458c --- /dev/null +++ b/src/hooks/useRecoveryQueue.ts @@ -0,0 +1,13 @@ +import { useAppSelector } from '@/store' +import { selectRecoveryQueues } from '@/store/recoverySlice' +import { useClock } from './useClock' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +export function useRecoveryQueue(): Array { + const queue = useAppSelector(selectRecoveryQueues) + const clock = useClock() + + return queue.filter(({ expiresAt }) => { + return expiresAt ? expiresAt.gt(clock) : true + }) +} diff --git a/src/hooks/useRecoveryTxState.ts b/src/hooks/useRecoveryTxState.ts index 97b6f103eb..4c39fe56a3 100644 --- a/src/hooks/useRecoveryTxState.ts +++ b/src/hooks/useRecoveryTxState.ts @@ -3,7 +3,6 @@ import { useAppSelector } from '@/store' import { selectDelayModifierByTxHash } from '@/store/recoverySlice' import type { RecoveryQueueItem } from '@/store/recoverySlice' -// TODO: Test export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args }: RecoveryQueueItem): { isNext: boolean isExecutable: boolean