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..6775d910c3 --- /dev/null +++ b/src/components/dashboard/RecoveryHeader/index.tsx @@ -0,0 +1,55 @@ +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 { RecoveryProposal } from '@/components/recovery/RecoveryModal/RecoveryProposal' +import { RecoveryInProgress } from '@/components/recovery/RecoveryModal/RecoveryInProgress' +import { WidgetContainer, WidgetBody } from '../styled' +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, { + 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/RecoveryModal/RecoveryInProgress.tsx b/src/components/recovery/RecoveryModal/RecoveryInProgress.tsx new file mode 100644 index 0000000000..95fcf6fdc3 --- /dev/null +++ b/src/components/recovery/RecoveryModal/RecoveryInProgress.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 = + | { + variant?: 'modal' + onClose: () => void + recovery: RecoveryQueueItem + } + | { + variant: 'widget' + onClose?: never + recovery: RecoveryQueueItem + } + +export function RecoveryInProgress({ variant = 'modal', 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 (variant === 'widget') { + return ( + + + {icon} + + + + {title} + + + + {desc} + + + + + + {link} + + + ) + } + + return ( + + + + {icon} + + {link} + + + + + {title} + + + {desc} + + + + + + + + + + ) +} diff --git a/src/components/recovery/RecoveryModal/RecoveryProposal.tsx b/src/components/recovery/RecoveryModal/RecoveryProposal.tsx new file mode 100644 index 0000000000..08ebb3713e --- /dev/null +++ b/src/components/recovery/RecoveryModal/RecoveryProposal.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 = + | { + variant?: 'modal' + onClose: () => void + safe: SafeInfo + setTxFlow: TxModalContextType['setTxFlow'] + } + | { + variant: 'widget' + onClose?: never + safe: SafeInfo + setTxFlow: TxModalContextType['setTxFlow'] + } + +export function _RecoveryProposal({ variant = 'modal', 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 (variant === 'widget') { + 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 RecoveryProposal = madProps(_RecoveryProposal, { + safe: _useSafe, + setTxFlow: _useSetTxFlow, +}) diff --git a/src/components/recovery/RecoveryModal/__tests__/RecoveryInProgress.test.tsx b/src/components/recovery/RecoveryModal/__tests__/RecoveryInProgress.test.tsx new file mode 100644 index 0000000000..8b45428347 --- /dev/null +++ b/src/components/recovery/RecoveryModal/__tests__/RecoveryInProgress.test.tsx @@ -0,0 +1,125 @@ +import { BigNumber } from 'ethers' +import { fireEvent, waitFor } from '@testing-library/react' + +import { render } from '@/tests/test-utils' +import { RecoveryInProgress } from '../RecoveryInProgress' +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.clearAllMocks() + }) + + describe('modal', () => { + 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('widget', () => { + 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/RecoveryModal/__tests__/RecoveryProposal.test.tsx b/src/components/recovery/RecoveryModal/__tests__/RecoveryProposal.test.tsx new file mode 100644 index 0000000000..f5184c0507 --- /dev/null +++ b/src/components/recovery/RecoveryModal/__tests__/RecoveryProposal.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 { _RecoveryProposal } from '../RecoveryProposal' + +describe('RecoveryProposal', () => { + describe('modal', () => { + it('should render correctly', () => { + const mockClose = jest.fn() + const mockSetTxFlow = jest.fn() + + const { queryByText } = render( + <_RecoveryProposal + variant="modal" + 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('widget', () => {}) + it('should render correctly', () => { + const mockSetTxFlow = jest.fn() + + const { queryByText } = render( + <_RecoveryProposal + variant="widget" + 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/RecoveryModal/__tests__/index.test.tsx b/src/components/recovery/RecoveryModal/__tests__/index.test.tsx new file mode 100644 index 0000000000..2d1fff928f --- /dev/null +++ b/src/components/recovery/RecoveryModal/__tests__/index.test.tsx @@ -0,0 +1,108 @@ +import { BigNumber } from 'ethers' +import * as router from 'next/router' + +import { render, waitFor } from '@/tests/test-utils' +import { _RecoveryModal } from '..' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +describe('RecoveryModal', () => { + it('should not render the modal if there is a queue but the user is not an owner or guardian', () => { + const { queryByText } = render( + <_RecoveryModal + isOwner={false} + isGuardian={false} + queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]} + > + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should not render the modal if there is no queue user and the user is a guardian', () => { + const { queryByText } = render( + <_RecoveryModal isOwner={false} isGuardian queue={[]}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should not render the modal if there is no queue user and the user is an owner', () => { + const { queryByText } = render( + <_RecoveryModal isOwner isGuardian={false} 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 { queryByText } = render( + <_RecoveryModal isOwner={false} isGuardian queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]}> + 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 { queryByText } = render( + <_RecoveryModal isOwner isGuardian={false} queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]}> + 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 { queryByText } = render( + <_RecoveryModal isOwner={false} isGuardian queue={[]}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Recover this Account')).toBeTruthy() + }) + + it('should close the modal when the user navigates away', async () => { + let onClose = () => {} + + jest.spyOn(router, 'useRouter').mockImplementation( + () => + ({ + events: { + on: jest.fn((_, callback) => { + onClose = callback + }), + }, + } as any), + ) + + const { queryByText } = render( + <_RecoveryModal isOwner isGuardian={false} queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Account recovery in progress')).toBeTruthy() + + onClose() + + await waitFor(() => { + expect(queryByText('Account recovery in progress')).toBeFalsy() + }) + }) +}) diff --git a/src/components/recovery/RecoveryModal/index.tsx b/src/components/recovery/RecoveryModal/index.tsx new file mode 100644 index 0000000000..78b76f5271 --- /dev/null +++ b/src/components/recovery/RecoveryModal/index.tsx @@ -0,0 +1,73 @@ +import { Backdrop, Fade } from '@mui/material' +import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import type { ReactElement, ReactNode } from 'react' + +import { useRecoveryQueue } from '@/hooks/useRecoveryQueue' +import { RecoveryInProgress } from './RecoveryInProgress' +import { RecoveryProposal } from './RecoveryProposal' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { useIsGuardian } from '@/hooks/useIsGuardian' +import madProps from '@/utils/mad-props' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +import css from './styles.module.css' + +export function _RecoveryModal({ + children, + isOwner, + isGuardian, + queue, +}: { + children: ReactNode + isOwner: boolean + isGuardian: boolean + queue: Array +}): ReactElement { + const [modal, setModal] = useState(null) + const router = useRouter() + + const next = queue[0] + + const onClose = () => { + setModal(null) + } + + // Trigger modal + useEffect(() => { + setModal(() => { + if (next) { + return + } + if (isGuardian && queue.length === 0) { + return + } + return null + }) + }, [queue.length, isOwner, isGuardian, next]) + + // Close modal on navigation + useEffect(() => { + router.events.on('routeChangeComplete', onClose) + return () => { + router.events.off('routeChangeComplete', onClose) + } + }, [router]) + + return ( + <> + + + {modal} + + + {children} + + ) +} + +export const RecoveryModal = madProps(_RecoveryModal, { + isOwner: useIsSafeOwner, + isGuardian: useIsGuardian, + queue: useRecoveryQueue, +}) diff --git a/src/components/recovery/RecoveryModal/styles.module.css b/src/components/recovery/RecoveryModal/styles.module.css new file mode 100644 index 0000000000..ce22c8fe25 --- /dev/null +++ b/src/components/recovery/RecoveryModal/styles.module.css @@ -0,0 +1,13 @@ +.backdrop { + z-index: 3; + background-color: var(--color-background-main); +} + +.card { + max-width: 576px; + padding: var(--space-4); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} 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 + }) +}