Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions packages/curve-ui-kit/src/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import { type MigrationOptions, useStoredState } from './useStoredState'

const { kebabCase } = lodash

// old keys that are not used anymore - clean them up
window.localStorage.removeItem('phishing-warning-dismissed')

function getFromLocalStorage<T>(storageKey: string): T | null {
if (typeof window === 'undefined') {
return null
Expand Down Expand Up @@ -72,3 +69,18 @@ export const useFavoriteMarkets = () => {
const initialValue = useMemo(() => [], [])
return useLocalStorage<Address[]>('favoriteMarkets', initialValue)
}

const ONE_MONTH_MS = 30 * 24 * 60 * 60 * 1000 // 30 days in milliseconds

export const usePhishingWarningDismissed = () => {
const [dismissedAt, setDismissedAt] = useLocalStorage<number | null>('phishing-warning-dismissed', null)

const shouldShowPhishingWarning = useMemo(() => {
if (dismissedAt == null) return true
const now = Date.now()
const timeSinceDismissed = now - dismissedAt
return timeSinceDismissed >= ONE_MONTH_MS // Show if dismissed more than a month ago
}, [dismissedAt])

return { dismissedAt, setDismissedAt, shouldShowPhishingWarning }
}
14 changes: 12 additions & 2 deletions packages/curve-ui-kit/src/shared/ui/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,25 +54,30 @@ export const Banner = ({
severity = 'default',
learnMoreUrl,
color,
subtitle,
testId,
}: {
onClick?: () => void
buttonText?: string
children: ReactNode
severity?: BannerSeverity
learnMoreUrl?: string
color?: TypographyProps['color']
subtitle?: ReactNode
testId?: string
}) => (
<Card
sx={{
display: 'flex',
gap: Spacing.md,
alignSelf: 'stretch',
paddingInline: Spacing.md,
paddingBlock: Spacing.xs,
alignItems: 'center',
alignItems: 'start',
justifyContent: 'center',
flexDirection: 'column',
...WrapperSx[severity],
}}
data-testid={testId}
>
<Stack
direction="row"
Expand Down Expand Up @@ -114,5 +119,10 @@ export const Banner = ({
</ChangeTheme>
</Stack>
</Stack>
<Stack direction="row" alignItems="center" justifyContent="start" height="100%">
<Typography color="textSecondary" variant="bodySRegular">
{subtitle}
</Typography>
</Stack>
</Card>
)
2 changes: 2 additions & 0 deletions packages/curve-ui-kit/src/shared/ui/GlobalBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { LlamaIcon } from '@ui-kit/shared/icons/LlamaIcon'
import { Banner } from '@ui-kit/shared/ui/Banner'
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
import { isCypress, ReleaseChannel } from '@ui-kit/utils'
import { PhishingWarningBanner } from '@ui-kit/widgets/Header/PhishingWarningBanner'

export type GlobalBannerProps = {
networkId: string
Expand All @@ -30,6 +31,7 @@ export const GlobalBanner = ({ networkId, chainId }: GlobalBannerProps) => {
const warnColor = useTheme().palette.mode === 'dark' ? '#000' : 'textSecondary' // todo: fix this in the design system of the alert component
return (
<Box>
<PhishingWarningBanner />
{releaseChannel !== ReleaseChannel.Stable && !isCypress && (
<Banner onClick={() => setReleaseChannel(ReleaseChannel.Stable)} buttonText={t`Disable ${releaseChannel} Mode`}>
<LlamaIcon sx={{ width: IconSize.sm, height: IconSize.sm }} /> {t`${releaseChannel} Mode Enabled`}
Expand Down
14 changes: 13 additions & 1 deletion packages/curve-ui-kit/src/shared/ui/stories/Banner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const meta: Meta<typeof Banner> = {
argTypes: {
severity: {
control: 'select',
options: ['error', 'warning', 'info'],
options: ['error', 'warning', 'info', 'highlight'],
description: 'The severity level of the banner message',
},
buttonText: {
Expand All @@ -35,6 +35,10 @@ const meta: Meta<typeof Banner> = {
action: 'clicked',
description: 'Function called when the button is clicked',
},
subtitle: {
control: 'text',
description: 'Subtitle for the banner message (optional)',
},
},
}

Expand Down Expand Up @@ -112,3 +116,11 @@ export const ApiErrorExample: Story = {
children: 'There is an issue connecting to the API. Please try to switch your RPC in your wallet settings.',
},
}

export const WithSubtitle: Story = {
args: {
severity: 'default',
children: 'This is an default message with a subtitle',
subtitle: 'This is a subtitle',
},
}
36 changes: 36 additions & 0 deletions packages/curve-ui-kit/src/widgets/Header/PhishingWarningBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { usePhishingWarningDismissed } from '@ui-kit/hooks/useLocalStorage'
import { t } from '@ui-kit/lib/i18n'
import { ExclamationTriangleIcon } from '@ui-kit/shared/icons/ExclamationTriangleIcon'
import { Banner } from '@ui-kit/shared/ui/Banner'
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'

const { IconSize } = SizesAndSpaces
const URL = 'https://www.curve.finance'

/**
* Displays a banner warning users about phishing risks and encourages them to verify they are on the official Curve domains.
* The banner will reappear after one month if dismissed.
*/
export const PhishingWarningBanner = () => {
const { setDismissedAt, shouldShowPhishingWarning } = usePhishingWarningDismissed()

const handleDismiss = () => {
setDismissedAt(Date.now())
}

if (!shouldShowPhishingWarning) {
return null
}

return (
<Banner
subtitle={t`Always carefully check that your URL is ${URL}.`}
severity="warning"
onClick={handleDismiss}
testId="phishing-warning-banner"
>
<ExclamationTriangleIcon sx={{ width: IconSize.sm, height: IconSize.sm, verticalAlign: 'text-bottom' }} />
{t`Make sure you are on the right domain`}
</Banner>
)
}
47 changes: 47 additions & 0 deletions tests/cypress/e2e/all/header.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
LOAD_TIMEOUT,
oneDesktopViewport,
oneMobileOrTabletViewport,
oneViewport,
SCROLL_WIDTH,
TABLET_BREAKPOINT,
} from '@cy/support/ui'
Expand All @@ -26,6 +27,7 @@ describe('Header', () => {
beforeEach(() => {
viewport = oneDesktopViewport()
cy.viewport(...viewport)
dismissPhishingWarningBanner()
route = oneAppRoute()
cy.visit(`/${route}`)
waitIsLoaded(route)
Expand Down Expand Up @@ -88,6 +90,7 @@ describe('Header', () => {
beforeEach(() => {
viewport = oneMobileOrTabletViewport()
cy.viewport(...viewport)
dismissPhishingWarningBanner()
route = oneAppRoute()
cy.visit(`/${route}`)
waitIsLoaded(route)
Expand Down Expand Up @@ -160,6 +163,50 @@ describe('Header', () => {
})
})

describe('Phishing Warning Banner', () => {
let route: AppRoute

beforeEach(() => {
const [width, height] = oneViewport()
viewport = [width, height]
cy.viewport(...viewport)
route = oneAppRoute()
cy.visit(`/${route}`)
waitIsLoaded(route)
})

it('should display the banner and allow dismissal', () => {
cy.get("[data-testid='phishing-warning-banner']").should('be.visible')
// Click the banner to dismiss it
cy.get("[data-testid='phishing-warning-banner']").find('button').first().click()
cy.get("[data-testid='phishing-warning-banner']").should('not.exist')
})

it('should reappear after one month', () => {
// Set dismissal date to 31 days ago (more than one month)
const oneMonthAgo = Date.now() - 31 * 24 * 60 * 60 * 1000
dismissPhishingWarningBanner(oneMonthAgo)
cy.reload()
waitIsLoaded(route)
cy.get("[data-testid='phishing-warning-banner']").should('be.visible')
})

it('should remain hidden within one month', () => {
// Set dismissal date to 15 days ago (less than one month)
const fifteenDaysAgo = Date.now() - 15 * 24 * 60 * 60 * 1000
dismissPhishingWarningBanner(fifteenDaysAgo)
cy.reload()
waitIsLoaded(route)
cy.get("[data-testid='phishing-warning-banner']").should('not.exist')
})
})

function dismissPhishingWarningBanner(date?: number) {
cy.window().then((win) => {
win.localStorage.setItem('phishing-warning-dismissed', JSON.stringify(date ?? Date.now()))
})
}

function waitIsLoaded(route: AppRoute) {
cy.get(`[data-testid='${getRouteTestId(route)}']`, API_LOAD_TIMEOUT).should('be.visible')
}
Expand Down