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