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
+ })
+}