Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
120 changes: 113 additions & 7 deletions src/components/Global/ReConsentModal/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/** @jest-environment jsdom */
/**
* ReConsentModal — the ToS §17 blocking click-through.
* ReConsentModal — the ToS §17 click-through.
*
* This modal gates the entire app, so the safety properties matter more than
* the happy path: a failed status check must NEVER lock the app (fail-open),
* a failed accept must keep the retry path alive, and an account switch must
* not leak the previous user's consent state (a regression already caught
* once in review).
* The safety properties matter more than the happy path: a failed status check
* must NEVER block the app (fail-open), a failed accept must keep the retry
* path alive, an account switch must not leak the previous user's consent state
* (a regression already caught once in review), and — because §17.2 gives a
* 30-day runway and §17.3 requires a decliner to still reach their funds — the
* prompt must ALWAYS be escapable and must never ledger a refusal as consent.
*/
import React from 'react'
import { render, screen, fireEvent, act } from '@testing-library/react'
Expand Down Expand Up @@ -64,14 +65,49 @@ jest.mock('@/components/Global/DocsLink', () => ({
default: ({ children }: { children: React.ReactNode }) => <a>{children}</a>,
}))

jest.mock('posthog-js', () => ({ capture: jest.fn() }))
const mockCapture = jest.fn()
jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) }))

const mockCaptureException = jest.fn()
jest.mock('@sentry/nextjs', () => ({
captureException: (...args: unknown[]) => mockCaptureException(...args),
}))

import ReConsentModal from '../index'
import { nextPromptAt, LEGAL_NOTICE_PERIOD_DAYS, MIN_SNOOZE_DAYS } from '../utils'

const DAY_MS = 24 * 60 * 60 * 1000

describe('nextPromptAt', () => {
// 2026-07-15 + 30d = 2026-08-14, the date the current terms take effect
const posted = '2026-07-15'
const dayAfterPosting = Date.parse('2026-07-16')

it('defers to the effective date — the full §17.2 notice period, not a fixed interval', () => {
expect(nextPromptAt([posted], dayAfterPosting)).toBe(Date.parse('2026-08-14'))
})

it('uses the LATEST effective date when several documents are shown at once', () => {
expect(nextPromptAt(['2026-07-01', posted], dayAfterPosting)).toBe(Date.parse('2026-08-14'))
})

it('floors to MIN_SNOOZE_DAYS once a document is already past its notice period', () => {
// §17.3 already makes continued use acceptance here, so the prompt only
// still asks to record explicit consent — it must not nag every open
const longAfter = Date.parse('2027-01-01')
expect(nextPromptAt([posted], longAfter)).toBe(longAfter + MIN_SNOOZE_DAYS * DAY_MS)
})

it('falls back to the floor rather than throwing on an unparseable version', () => {
const now = Date.parse('2026-07-29')
expect(nextPromptAt(['not-a-date'], now)).toBe(now + MIN_SNOOZE_DAYS * DAY_MS)
expect(nextPromptAt([], now)).toBe(now + MIN_SNOOZE_DAYS * DAY_MS)
})

it('pins the notice period to the 30 days our own ToS §17.2 promises', () => {
expect(LEGAL_NOTICE_PERIOD_DAYS).toBe(30)
})
})

const statusDoc = (slug: string) => ({
slug,
Expand All @@ -85,9 +121,14 @@ const flush = () => act(async () => {})

beforeEach(() => {
jest.clearAllMocks()
// the snooze is persisted per-user in localStorage — a leaked entry would
// silently suppress the prompt in every later test
window.localStorage.clear()
mockUser = { user: { userId: 'user-1' } }
})

const capturedEvents = () => mockCapture.mock.calls.map(([event]) => event)

describe('ReConsentModal', () => {
it('fails open: a failed status check never shows (or locks) the modal', async () => {
mockGetStatus.mockRejectedValue(new Error('api down'))
Expand Down Expand Up @@ -138,6 +179,71 @@ describe('ReConsentModal', () => {
expect.objectContaining({ slug: 'privacy' }),
])
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
// acceptance must be distinguishable from refusal in analytics —
// the accept-vs-postpone ratio is the rollout's headline metric
expect(capturedEvents()).toEqual(['modal_shown', 'modal_cta_clicked'])
})

it('"Not now" is always available and never gated on the checkbox', async () => {
mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] })
render(<ReConsentModal />)
await flush()

expect(screen.getByText('Accept & continue')).toBeDisabled()
// the escape hatch must not require ticking a consent box first
expect(screen.getByText('Not now')).not.toBeDisabled()
})

it('"Not now" dismisses without recording any consent (a refusal is not a ledger row)', async () => {
mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] })
render(<ReConsentModal />)
await flush()

await act(async () => {
fireEvent.click(screen.getByText('Not now'))
})

expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
expect(mockAccept).not.toHaveBeenCalled()
expect(capturedEvents()).toEqual(['modal_shown', 'modal_dismissed'])
})

it('a postponed prompt stays away on the next session, then returns once the snooze lapses', async () => {
mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] })
const first = render(<ReConsentModal />)
await flush()
await act(async () => {
fireEvent.click(screen.getByText('Not now'))
})
first.unmount()

// fresh session (remount → fresh refs), still inside the snooze window
render(<ReConsentModal />)
await flush()
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
// and we don't even spend the request while snoozed
expect(mockGetStatus).toHaveBeenCalledTimes(1)

// snooze lapses → the prompt comes back
window.localStorage.setItem('peanut.reconsent.snoozedUntil.user-1', String(Date.now() - 1))
render(<ReConsentModal />)
await flush()
expect(screen.getByTestId('modal')).toBeInTheDocument()
})

it('one user postponing does not suppress the prompt for a different account', async () => {
mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] })
const first = render(<ReConsentModal />)
await flush()
await act(async () => {
fireEvent.click(screen.getByText('Not now'))
})
first.unmount()

mockUser = { user: { userId: 'user-2' } }
render(<ReConsentModal />)
await flush()
expect(screen.getByTestId('modal')).toBeInTheDocument()
})

it('a failed accept keeps the modal, shows the error, and leaves retry enabled', async () => {
Expand Down
74 changes: 58 additions & 16 deletions src/components/Global/ReConsentModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useAuth } from '@/context/authContext'
import { acceptedLegalDocument, consentApi, type ConsentStatusDocument } from '@/services/consent'
import { LEGAL_DOCUMENT_VERSIONS, type LegalDocumentSlug } from '@/constants/legal-versions.generated'
import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts'
import { isReConsentSnoozed, snoozeReConsent } from './utils'

const DOC_LABELS: Record<string, { name: string; href: string }> = {
terms: { name: 'Terms of Service', href: '/terms' },
Expand All @@ -19,12 +20,21 @@ const DOC_LABELS: Record<string, { name: string; href: string }> = {
'card-prohibited-activities': { name: 'Prohibited Activities Policy', href: '/card-prohibited-activities' },
}

/** Keep "Not now" stacked BELOW the primary CTA at every width — side-by-side
* would read as two equally-weighted choices. */
const STACKED_CTAS = 'flex-col sm:flex-col'

/**
* Re-consent click-through (tos-v1 phase 2, ToS §17): when a legal document's
* published version moves past what the user last provably accepted, this
* blocking modal lists the updated documents and appends fresh consent-ledger
* rows on acceptance. Backed by GET /users/consent/status and
* POST /users/consent/accept — see peanut-api.
* published version moves past what the user last provably accepted, this modal
* lists the updated documents and appends fresh consent-ledger rows on
* acceptance. Backed by GET /users/consent/status and POST /users/consent/accept.
*
* It is a PROMPT, not a gate. §17.2 gives material changes 30 days and offers
* the click-through as a way to accept sooner; §17.3 requires that a user who
* declines can still stop using the Services — which, for a non-custodial
* wallet, means they must be able to reach `/withdraw`. So "Not now" always
* exists, and dismissing never writes a ledger row (declining is not consent).
*/
const ReConsentModal = () => {
const { user } = useAuth()
Expand All @@ -45,6 +55,8 @@ const ReConsentModal = () => {
setOutdatedDocs([])
setChecked(false)
setError(null)
// a recent "Not now" defers the prompt — don't even spend the request
if (isReConsentSnoozed(userId)) return
consentApi
.getStatus()
.then((status) => {
Expand All @@ -62,7 +74,7 @@ const ReConsentModal = () => {
})
})
.catch((e) => {
// a failed status check must never lock the app — retry next
// a failed status check must never block the app — retry next
// session. Sentry (not console): a systematic failure here means
// re-consent silently stops rolling out, and prod must say so.
Sentry.captureException(e, { tags: { feature: 're-consent', action: 'status' } })
Expand All @@ -75,7 +87,9 @@ const ReConsentModal = () => {
setError(null)
try {
await consentApi.accept(outdatedDocs.map((d) => acceptedLegalDocument(d.slug as LegalDocumentSlug)))
posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, {
// CTA_CLICKED, not DISMISSED — acceptance and refusal must be
// distinguishable, since their ratio is the rollout's headline metric
posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, {
modal_type: MODAL_TYPES.RE_CONSENT,
documents: outdatedDocs.map((d) => d.slug),
})
Expand All @@ -84,44 +98,63 @@ const ReConsentModal = () => {
// must start with an unticked box
setChecked(false)
} catch (e) {
// Sentry (not console): if /accept fails systematically, every user
// sits behind this undismissable modal — that must be visible in prod
// Sentry (not console): if /accept fails systematically, nobody can
// record consent at all — that must be visible in prod
Sentry.captureException(e, { tags: { feature: 're-consent', action: 'accept' } })
setError('Could not save your acceptance — please try again.')
} finally {
setSubmitting(false)
}
}

/** "Not now", the close button, backdrop and Escape all land here. */
const handlePostpone = () => {
if (submitting) return
const userId = user?.user.userId
// defer to the date these documents actually take effect (§17.2), not a
// fixed interval — a doc posted today buys the user its full 30 days
if (userId)
snoozeReConsent(
userId,
outdatedDocs.map((d) => d.currentVersion)
)
posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, {
modal_type: MODAL_TYPES.RE_CONSENT,
documents: outdatedDocs.map((d) => d.slug),
})
setOutdatedDocs([])
setChecked(false)
setError(null)
}

if (!outdatedDocs.length) return null

return (
<ActionModal
visible
onClose={() => undefined}
preventClose
hideModalCloseButton
onClose={handlePostpone}
icon="info"
title="We've updated our terms"
content={
<>
<div className="w-full space-y-3 text-left">
<p className="text-sm text-grey-1">
To keep using Peanut, please review and accept the updated documents:
We've refreshed the documents below. Give them a read and accept when you're ready — you can
keep using Peanut in the meantime.
</p>
<ul className="list-disc pl-5 text-sm">
<ul className="space-y-1 text-sm">
{outdatedDocs.map((doc) => {
const label = DOC_LABELS[doc.slug] ?? { name: doc.slug, href: `/${doc.slug}` }
return (
<li key={doc.slug}>
<DocsLink href={label.href} className="underline">
<DocsLink href={label.href} className="text-black underline dark:text-white">
{label.name}
</DocsLink>
</li>
)
})}
</ul>
{error && <p className="text-sm text-error">{error}</p>}
</>
</div>
}
checkbox={{
text: 'I have read and accept the updated documents',
Expand All @@ -131,10 +164,19 @@ const ReConsentModal = () => {
ctas={[
{
text: submitting ? 'Saving…' : 'Accept & continue',
variant: 'purple',
shadowSize: '4',
disabled: !checked || submitting,
onClick: handleAccept,
},
{
text: 'Not now',
variant: 'transparent',
disabled: submitting,
onClick: handlePostpone,
},
]}
ctaClassName={STACKED_CTAS}
/>
)
}
Expand Down
73 changes: 73 additions & 0 deletions src/components/Global/ReConsentModal/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Local snooze for the re-consent prompt.
*
* ToS §17.2 gives material changes a 30-day notice period and offers the in-app
* click-through as a way to accept sooner, by choice. §17.3 then says a user who
* does not agree must be able to stop using the Services — which, for a
* non-custodial wallet, has to include reaching their own funds. So the prompt
* is a reminder, not a lock: "Not now" defers it to the date the documents
* actually take effect.
*
* Deliberately localStorage and not the consent ledger — declining is not a
* consent event and must never write a row. Losing the snooze (cleared storage,
* another device) only re-shows the prompt, which is the safe direction.
*/

/** ToS §17.2 — material changes take effect no earlier than 30 days after posting. */
export const LEGAL_NOTICE_PERIOD_DAYS = 30

/**
* Floor for a document already past its notice period. Past that date §17.3
* makes continued use acceptance on its own, so the prompt only still asks in
* order to record explicit consent — worth a gentle cadence, not every app open.
*/
export const MIN_SNOOZE_DAYS = 7

const DAY_MS = 24 * 60 * 60 * 1000

const snoozeKey = (userId: string) => `peanut.reconsent.snoozedUntil.${userId}`

/** Storage is absent during SSR and throws outright in Safari private mode. */
function readStorage(key: string): string | null {
try {
return typeof window === 'undefined' ? null : window.localStorage.getItem(key)
} catch {
return null
}
}

function writeStorage(key: string, value: string): void {
try {
if (typeof window !== 'undefined') window.localStorage.setItem(key, value)
} catch {
// a full or blocked quota only means the prompt returns next session
}
}

/**
* When the prompt may reappear after a "Not now": the latest effective date
* across the documents shown, floored to MIN_SNOOZE_DAYS.
*
* `currentVersion` is the document's frontmatter `last_updated` (an ISO date),
* so effective = version + 30 days. An unparseable version contributes nothing
* rather than throwing — the floor still applies.
*/
export function nextPromptAt(versions: string[], now: number = Date.now()): number {
const effectiveDates = versions
.map((version) => Date.parse(version))
.filter((parsed) => Number.isFinite(parsed))
.map((parsed) => parsed + LEGAL_NOTICE_PERIOD_DAYS * DAY_MS)
return Math.max(now + MIN_SNOOZE_DAYS * DAY_MS, ...effectiveDates)
}

export function isReConsentSnoozed(userId: string): boolean {
const raw = readStorage(snoozeKey(userId))
if (!raw) return false
const until = Number(raw)
// a corrupt value must not suppress the prompt forever
return Number.isFinite(until) && until > Date.now()
}

export function snoozeReConsent(userId: string, versions: string[]): void {
writeStorage(snoozeKey(userId), String(nextPromptAt(versions)))
}
4 changes: 2 additions & 2 deletions src/constants/legal-versions.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export const LEGAL_DOCUMENT_VERSIONS = {
hash: 'd0ea9e1b239179c9ca4e5c26badb49506d8453399958ed040f68bb6650fd6e6c',
},
'card-terms-international': {
version: '2026-06-01',
hash: 'a6476057cd5bcf553f2c1faffc5261ad3c5e59eecbc8bf068e3b561df22035cf',
version: '2026-07-14',
hash: '9eb731e649de21ef8a28fca821f4123d63be55f0765a0843fe42bac31bbbe0bd',
},
'card-terms-us': {
version: '2026-06-01',
Expand Down
Loading