diff --git a/jest.setup.ts b/jest.setup.ts index a7452a20ed..302fdf49c4 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -22,6 +22,38 @@ global.ResizeObserver = class { disconnect() {} } +// jsdom has no IntersectionObserver; embla-carousel's SlidesInView needs it on init. +global.IntersectionObserver = class { + root = null + rootMargin = '' + thresholds = [] + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return [] + } +} as unknown as typeof IntersectionObserver + +// jsdom has no matchMedia; embla-carousel calls it for breakpoint options on init. +// Suites that assert on matchMedia behavior override this with their own stub. +// Guarded: a few suites run in the node environment, where window doesn't exist. +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }), + }) +} + // Add any global test setup here global.console = { ...console, diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index 07140bf537..4ce82f51ed 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -334,6 +334,7 @@ jest.mock('@/utils/currency', () => ({ })) jest.mock('@/utils/format.utils', () => ({ + ...jest.requireActual('@/utils/format.utils'), formatBankAccountDisplay: jest.fn((val: string) => val), })) diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 9174375ae6..0e7a78df7b 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -34,6 +34,7 @@ import { updateUserById } from '@/app/actions/users' import { useHaptic } from 'use-haptic' import { useActivationStatus } from '@/hooks/useActivationStatus' import ActivationCTAs from '@/components/Home/ActivationCTAs' +import PendingVerificationTasks from '@/components/Home/PendingVerificationTasks' import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' import underMaintenanceConfig from '@/config/underMaintenance.config' import posthog from 'posthog-js' @@ -214,6 +215,12 @@ export default function Home() { dismiss/click hides it forever. Rendered above the carousel/activation CTAs so it leads the home stack on launch day. */} + {/* Pending Bridge verification tasks (ToS / hosted re-verification). + Sibling of ActivationCTAs on purpose: it must show for users + who can already transact (the advisory cohort), whom the + activation card deliberately stands down for. Self-hiding; + dismissible here — resurfaces under Profile → Unlocked regions. */} + {isActivated ? ( ) : ( diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 017bde0d66..f802dd5dba 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -116,6 +116,31 @@ export const initiateSelfHealResubmission = async ( } } +/** + * Exchange the `bridge-hosted` capability action for Bridge's hosted + * verification URL (same POST /users/kyc/start-action endpoint as + * {@link startKycAction}, different response shape: that path mints Sumsub + * tokens, this one returns a URL — hence its own guard). + */ +export const startBridgeHostedVerification = async (): Promise<{ url?: string; error?: string }> => { + try { + const response = await serverFetch('/users/kyc/start-action', { + method: 'POST', + body: JSON.stringify({ key: 'bridge-hosted' }), + }) + const responseJson = await response.json() + if (!response.ok) { + return { error: responseJson.userMessage || responseJson.error || 'Failed to start verification' } + } + if (!responseJson.verificationUrl) { + return { error: 'Invalid response from server' } + } + return { url: responseJson.verificationUrl } + } catch (e: unknown) { + return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } + } +} + export interface StartKycActionResponse { token: string levelName: string diff --git a/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx b/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx new file mode 100644 index 0000000000..8914bf1e7e --- /dev/null +++ b/src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx @@ -0,0 +1,68 @@ +/** + * IframeWrapper message routing — the wrapper reacts ONLY to postMessages + * from ITS OWN iframe (source identity), never to a sibling's. The previous + * guard keyed on `visible`, which (a) let two concurrently-visible wrappers + * fire each other's handlers (double ToS confirms + phantom flow + * transitions) and (b) dropped a real completion that landed in the instant + * a modal was hiding — the acceptance existed at Bridge but was never + * confirmed in-app. + */ +import React from 'react' +import { render, act } from '@testing-library/react' +import IframeWrapper from '../index' + +jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn() }) })) +jest.mock('@/context/ModalsContext', () => ({ + useModalsContext: () => ({ setIsSupportModalOpen: jest.fn() }), +})) + +function findIframe(src: string): HTMLIFrameElement { + // headlessui Dialog portals into document.body — search the document. + const iframe = Array.from(document.querySelectorAll('iframe')).find((f) => f.getAttribute('src') === src) + if (!iframe) throw new Error(`iframe with src ${src} not mounted`) + return iframe +} + +function postFrom(source: Window | null, data: unknown) { + act(() => { + window.dispatchEvent(new MessageEvent('message', { data, source: source as MessageEventSource | null })) + }) +} + +describe('IframeWrapper message routing', () => { + it("handles its OWN iframe's completion and ToS messages", () => { + const onClose = jest.fn() + render() + const own = findIframe('https://one.test/flow').contentWindow + + postFrom(own, { name: 'complete', metadata: { status: 'completed' } }) + expect(onClose).toHaveBeenCalledWith('completed') + + postFrom(own, { signedAgreementId: 'sig-1' }) + expect(onClose).toHaveBeenCalledWith('tos_accepted') + }) + + it("ignores a SIBLING iframe's message even when BOTH wrappers are visible", () => { + const onCloseA = jest.fn() + const onCloseB = jest.fn() + render( + <> + + + > + ) + + postFrom(findIframe('https://b.test/hosted').contentWindow, { signedAgreementId: 'sig-b' }) + expect(onCloseB).toHaveBeenCalledWith('tos_accepted') + expect(onCloseA).not.toHaveBeenCalled() + }) + + it('ignores messages with no source (nothing to attribute them to)', () => { + const onClose = jest.fn() + render() + + postFrom(null, { name: 'complete', metadata: { status: 'completed' } }) + postFrom(null, { signedAgreementId: 'sig-x' }) + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Global/IframeWrapper/index.tsx b/src/components/Global/IframeWrapper/index.tsx index 363cfefedb..5fc64bc997 100644 --- a/src/components/Global/IframeWrapper/index.tsx +++ b/src/components/Global/IframeWrapper/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import Modal from '../Modal' import { Icon, type IconName } from '../Icons/Icon' import ActionModal from '../ActionModal' @@ -18,6 +18,7 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble') const [copied, setCopied] = useState(false) + const iframeRef = useRef(null) const router = useRouter() const { setIsSupportModalOpen } = useModalsContext() @@ -96,6 +97,16 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra // track completed event from iframe and close the modal useEffect(() => { const handleMessage = (event: MessageEvent) => { + // React only to messages from OUR iframe. Several surfaces keep a + // wrapper mounted after a manual close (e.g. the multi-phase KYC + // flow's ToS iframe), and a sibling iframe's completion event would + // otherwise fire BOTH handlers — double ToS confirms + phantom flow + // transitions. Matching on the message SOURCE (not `visible`) keeps + // that protection — even against a sibling that is visible at the + // same time — without dropping a completion that lands in the + // instant the modal is hiding: the acceptance already happened at + // Bridge, and never confirming it strands a stale task. + if (!event.source || event.source !== iframeRef.current?.contentWindow) return const data = event.data if (data?.name === 'complete' && data?.metadata?.status === 'completed') { onClose('completed') @@ -136,6 +147,7 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra (null) + const [hostedUrl, setHostedUrl] = useState(null) + const [isStartingHosted, setIsStartingHosted] = useState(false) + const [error, setError] = useState(null) + // Stored dismissals, tagged with the user they were loaded for + // (localStorage is unreadable during SSR, hence the post-render effect). + // The dismissible mount must not paint until the CURRENT user's entry is + // hydrated: an empty list would flash already-dismissed tasks, and an + // untagged list would leak the previous user's dismissals for one render + // after a logout/login. + const [storedDismissals, setStoredDismissals] = useState<{ forUserId: string; keys: string[] } | null>(null) + + const userId = user?.user?.userId + const tasks = selectBridgeTasks(nextActions) + const taskKeys = tasks + .map((task) => task.key) + .sort() + .join(',') + + // A new task set means the previous failure context is gone. + useEffect(() => { + setError(null) + }, [taskKeys]) + + useEffect(() => { + if (!dismissible || !userId) return + setStoredDismissals({ + forUserId: userId, + keys: getUserPreferences(userId)?.pendingVerificationTasksDismissed ?? [], + }) + }, [dismissible, userId]) + + // Hydrated only when the stored entry belongs to the current user. + const dismissedKeys = storedDismissals && storedDismissals.forUserId === userId ? storedDismissals.keys : null + + const handleDismissTask = useCallback( + (task: NextAction) => { + if (!userId) return + setStoredDismissals((prev) => { + const keys = prev && prev.forUserId === userId ? prev.keys : [] + const next = [...keys, bridgeTaskDismissalKey(task)] + updateUserPreferences(userId, { pendingVerificationTasksDismissed: next }) + return { forUserId: userId, keys: next } + }) + }, + [userId] + ) + + // Only ADVISORY (future-dated) tasks honor dismissals. A blocking task's + // fingerprint is constant over time (`accept-tos||due-now`), so an old + // stored dismissal would silently hide a NEW same-variant requirement + // months later while the user's rails are gated — and for the orphan + // bridge-hosted task this card is the only actionable surface outside + // Profile (/code-review 08-04). Blocking tasks therefore always render; + // advisory ones hold the first paint until stored dismissals hydrate + // (an empty list would flash already-dismissed slides). + const visibleTasks = !dismissible + ? tasks + : tasks.filter( + (task) => + !task.effectiveDate || + (dismissedKeys !== null && !dismissedKeys.includes(bridgeTaskDismissalKey(task))) + ) + + const handleOpenTask = useCallback( + async (task: NextAction) => { + setError(null) + if (task.kind === 'accept-tos') { + setActiveTosTask(task) + return + } + setIsStartingHosted(true) + const { url } = await startBridgeHostedVerification() + setIsStartingHosted(false) + if (!url) { + // Friendly copy regardless of the server detail (a 403 here just + // means the action aged out); refetch so a stale card self-corrects. + setError("We couldn't start the verification. Please try again in a moment.") + void fetchUser() + return + } + setHostedUrl(url) + }, + [fetchUser] + ) + + const handleHostedClose = useCallback( + (source?: 'manual' | 'completed' | 'tos_accepted') => { + // Bridge's hosted kyc_link flow can EMBED a ToS-acceptance step + // before the identity steps — exactly for this cohort, which owes + // both. The wrapper maps that step's signedAgreementId postMessage + // to 'tos_accepted'; treating it as a close would kill the + // verification mid-flow. Keep the iframe open and CONFIRM the + // acceptance to the backend — fetchUser alone re-reads stored + // state (the resolver is pure, no Bridge calls), so without the + // confirm POST the accept-tos task stays visible until a Bridge + // webhook lands and tapping it 409s "already accepted". Same + // canonical path as BridgeTosStep / useMultiPhaseKycFlow; it + // refetches the user itself. Only 'completed' / 'manual' close. + if (source === 'tos_accepted') { + void confirmBridgeTosAndAwaitRails(fetchUser).catch(() => void fetchUser()) + return + } + setHostedUrl(null) + if (source === 'completed') { + // Bridge re-checks the customer asynchronously — refresh so the + // task clears as soon as the capability model catches up. + void fetchUser() + } + }, + [fetchUser] + ) + + const closeTos = useCallback(() => setActiveTosTask(null), []) + + if (visibleTasks.length === 0 && !activeTosTask && !hostedUrl) return null + + return ( + <> + {visibleTasks.length > 0 && ( + + + {visibleTasks.map((task) => { + const copy = taskCopy(task) + const isHosted = task.kind === 'bridge-hosted' + const deadline = formatEffectiveDate(task.effectiveDate) + return ( + + + {dismissible && !!task.effectiveDate && ( + handleDismissTask(task)} + className="absolute right-3 top-3 z-10 cursor-pointer p-0 text-black outline-none" + > + + + )} + + + + + {copy.title} + {copy.description} + {deadline && ( + + Complete before {deadline} + + )} + + handleOpenTask(task)} + > + {isHosted + ? isStartingHosted + ? 'Loading...' + : 'Complete verification' + : 'Review terms'} + + + + ) + })} + + {error && {error}} + + )} + + {activeTosTask && ( + + )} + + {hostedUrl && } + > + ) +} diff --git a/src/components/Home/__tests__/PendingVerificationTasks.test.tsx b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx new file mode 100644 index 0000000000..234ad6987d --- /dev/null +++ b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx @@ -0,0 +1,343 @@ +/** + * PendingVerificationTasks — the Home card mirroring Bridge's "additional + * verification needed" dashboard state. + * + * Reads top-level capability nextActions (not rail gates) so it catches both + * blocking tasks and advisory orphans (future-dated tasks on fully-enabled + * users, which no rail references). accept-tos routes into the existing + * BridgeTosStep; bridge-hosted exchanges the key for a hosted URL and opens + * it in the IframeWrapper. Open flows are snapshotted at tap time so they + * survive the task list flapping under the ~4s user auto-refresh. + */ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import type { NextAction } from '@/types/capabilities' +import PendingVerificationTasks from '../PendingVerificationTasks' + +let mockNextActions: NextAction[] = [] +const mockFetchUser = jest.fn() +const mockStartHosted = jest.fn, []>() +let mockStoredDismissal: string[] | undefined +const mockUpdatePreferences = jest.fn() + +jest.mock('@/hooks/useCapabilities', () => ({ + useCapabilities: () => ({ nextActions: mockNextActions }), +})) +let mockUserId = 'user-1' +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: { user: { userId: mockUserId } }, fetchUser: mockFetchUser }), +})) +jest.mock('@/utils/general.utils', () => ({ + getUserPreferences: () => ({ pendingVerificationTasksDismissed: mockStoredDismissal }), + updateUserPreferences: (userId: string, prefs: Record) => mockUpdatePreferences(userId, prefs), +})) +jest.mock('@/app/actions/sumsub', () => ({ + startBridgeHostedVerification: () => mockStartHosted(), +})) +const mockConfirmTos = jest.fn, [unknown]>() +jest.mock('@/hooks/useMultiPhaseKycFlow', () => ({ + confirmBridgeTosAndAwaitRails: (fetchUser: () => Promise) => mockConfirmTos(fetchUser), +})) +jest.mock('@/components/Kyc/BridgeTosStep', () => ({ + BridgeTosStep: (props: { visible: boolean; reasonCode?: string }) => + props.visible ? {props.reasonCode} : null, +})) +jest.mock('@/components/Global/IframeWrapper', () => ({ + __esModule: true, + default: (props: { src: string; visible: boolean; onClose: (source?: string) => void }) => + props.visible ? ( + + props.onClose('completed')}>finish + props.onClose('manual')}>close + props.onClose('tos_accepted')}>accept-embedded-tos + + ) : null, +})) + +const tosAction: NextAction = { key: 'accept-tos', kind: 'accept-tos', purpose: 'accept-bridge-tos' } +const sepaTosAction: NextAction = { + key: 'accept-tos:sepa', + kind: 'accept-tos', + purpose: 'accept-bridge-tos-sepa', +} +const hostedAction: NextAction = { + key: 'bridge-hosted', + kind: 'bridge-hosted', + purpose: 'bridge-additional-verification', + requirementKey: 'kyc_approval', +} + +describe('PendingVerificationTasks', () => { + beforeEach(() => { + mockNextActions = [] + mockFetchUser.mockReset() + mockStartHosted.mockReset() + mockConfirmTos.mockReset() + mockConfirmTos.mockResolvedValue(undefined) + mockStoredDismissal = undefined + mockUpdatePreferences.mockReset() + mockUserId = 'user-1' + }) + + it('renders nothing when no bridge task is pending', () => { + mockNextActions = [{ key: 'sumsub:proof_of_address', kind: 'sumsub', purpose: 'unlock-bridge' }] + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('accept-tos task opens BridgeTosStep with the variant-matched reason code', () => { + mockNextActions = [sepaTosAction] + render() + + expect(screen.getByText('Accept SEPA Terms of Service')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /review terms/i })) + expect(screen.getByTestId('tos-step')).toHaveTextContent('bridge_tos_v2_required') + }) + + it('with BOTH ToS variants pending, each row opens the modal for ITS variant', () => { + mockNextActions = [tosAction, sepaTosAction] + render() + + expect(screen.getByText('Accept Terms of Service')).toBeInTheDocument() + expect(screen.getByText('Accept SEPA Terms of Service')).toBeInTheDocument() + + const buttons = screen.getAllByRole('button', { name: /review terms/i }) + fireEvent.click(buttons[1]) // sepa row (render order follows nextActions) + expect(screen.getByTestId('tos-step')).toHaveTextContent('bridge_tos_v2_required') + }) + + it('bridge-hosted task fetches the hosted URL, opens the iframe, and refreshes the user on completion', async () => { + mockNextActions = [hostedAction] + mockStartHosted.mockResolvedValue({ url: 'https://bridge.withpersona.com/verify?x=1' }) + render() + + expect(screen.getByText('Additional verification needed')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /complete verification/i })) + + const iframe = await screen.findByTestId('hosted-iframe') + expect(iframe).toHaveAttribute('data-src', 'https://bridge.withpersona.com/verify?x=1') + + fireEvent.click(screen.getByText('finish')) + await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)) + expect(screen.queryByTestId('hosted-iframe')).not.toBeInTheDocument() + }) + + it('an open hosted iframe SURVIVES its task disappearing from nextActions (auto-refresh flap)', async () => { + mockNextActions = [hostedAction] + mockStartHosted.mockResolvedValue({ url: 'https://bridge.withpersona.com/verify?x=1' }) + const { rerender } = render() + + fireEvent.click(screen.getByRole('button', { name: /complete verification/i })) + await screen.findByTestId('hosted-iframe') + + // Bridge reclassifies mid-flow → the task vanishes on the next refetch. + mockNextActions = [] + rerender() + + expect(screen.queryByText('Additional verification needed')).not.toBeInTheDocument() + expect(screen.getByTestId('hosted-iframe')).toBeInTheDocument() + + fireEvent.click(screen.getByText('close')) + expect(screen.queryByTestId('hosted-iframe')).not.toBeInTheDocument() + }) + + it('manual iframe close does not refetch the user', async () => { + mockNextActions = [hostedAction] + mockStartHosted.mockResolvedValue({ url: 'https://bridge.withpersona.com/verify?x=1' }) + render() + + fireEvent.click(screen.getByRole('button', { name: /complete verification/i })) + await screen.findByTestId('hosted-iframe') + fireEvent.click(screen.getByText('close')) + + expect(screen.queryByTestId('hosted-iframe')).not.toBeInTheDocument() + expect(mockFetchUser).not.toHaveBeenCalled() + }) + + it('start-action failure surfaces FRIENDLY copy (never the raw server error) and resyncs the user', async () => { + mockNextActions = [hostedAction] + mockStartHosted.mockResolvedValue({ error: 'Action not allowed for this user' }) + render() + + fireEvent.click(screen.getByRole('button', { name: /complete verification/i })) + expect(await screen.findByText(/couldn't start the verification/i)).toBeInTheDocument() + expect(screen.queryByText('Action not allowed for this user')).not.toBeInTheDocument() + expect(screen.queryByTestId('hosted-iframe')).not.toBeInTheDocument() + expect(mockFetchUser).toHaveBeenCalledTimes(1) + }) + + it('an EMBEDDED ToS step inside the hosted flow CONFIRMS to the backend and does NOT close the iframe', async () => { + mockNextActions = [hostedAction] + mockStartHosted.mockResolvedValue({ url: 'https://bridge.withpersona.com/verify?x=1' }) + render() + + fireEvent.click(screen.getByRole('button', { name: /complete verification/i })) + await screen.findByTestId('hosted-iframe') + + // Bridge's hosted kyc_link flow can open with a ToS-acceptance page; + // its signedAgreementId postMessage maps to onClose('tos_accepted'). + // A bare fetchUser would NOT record the acceptance (the resolver is + // pure) — the canonical confirm path must run, and it refetches. + fireEvent.click(screen.getByText('accept-embedded-tos')) + expect(screen.getByTestId('hosted-iframe')).toBeInTheDocument() + await waitFor(() => expect(mockConfirmTos).toHaveBeenCalledTimes(1)) + expect(mockConfirmTos).toHaveBeenCalledWith(mockFetchUser) + + // The user then finishes the identity steps — completion still closes. + fireEvent.click(screen.getByText('finish')) + expect(screen.queryByTestId('hosted-iframe')).not.toBeInTheDocument() + await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)) + }) + + it('a failed embedded-ToS confirm still resyncs the user and keeps the flow alive', async () => { + mockNextActions = [hostedAction] + mockStartHosted.mockResolvedValue({ url: 'https://bridge.withpersona.com/verify?x=1' }) + mockConfirmTos.mockRejectedValue(new Error('confirm blew up')) + render() + + fireEvent.click(screen.getByRole('button', { name: /complete verification/i })) + await screen.findByTestId('hosted-iframe') + + fireEvent.click(screen.getByText('accept-embedded-tos')) + await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)) + expect(screen.getByTestId('hosted-iframe')).toBeInTheDocument() + }) + + it('advisory task renders its deadline and keep-access copy; blocking renders enable copy', () => { + mockNextActions = [{ ...hostedAction, effectiveDate: '2099-09-01' }] + const { rerender } = render() + // Long month — the SAME formatter AdvisoryPreemptModal uses + // (formatEffectiveDate), so one deadline never renders two ways. + expect(screen.getByText(/complete before september 1, 2099/i)).toBeInTheDocument() + expect(screen.getByText(/keep bank transfers available/i)).toBeInTheDocument() + + mockNextActions = [hostedAction] + rerender() + expect(screen.getByText(/enable bank transfers/i)).toBeInTheDocument() + expect(screen.queryByText(/complete before/i)).not.toBeInTheDocument() + }) + + it('malformed effectiveDate renders no deadline line instead of "Invalid Date"', () => { + mockNextActions = [{ ...hostedAction, effectiveDate: 'not-a-date' }] + render() + expect(screen.queryByText(/complete before/i)).not.toBeInTheDocument() + expect(screen.queryByText(/invalid date/i)).not.toBeInTheDocument() + }) + + it('renders both tasks when ToS and hosted verification are pending together', () => { + mockNextActions = [tosAction, hostedAction] + render() + expect(screen.getByText('Accept Terms of Service')).toBeInTheDocument() + expect(screen.getByText('Additional verification needed')).toBeInTheDocument() + }) + + describe('dismissal (home mount)', () => { + // Only ADVISORY (future-dated) tasks are dismissible — a blocking + // fingerprint is constant over time, so honoring one would hide a NEW + // same-variant requirement while the user's rails are gated. + const advisoryTos: NextAction = { + ...sepaTosAction, + requirementKey: 'tos_v2_acceptance', + effectiveDate: '2099-09-01', + } + const advisoryHosted: NextAction = { ...hostedAction, effectiveDate: '2099-12-01' } + const advisoryTosFingerprint = 'accept-tos:sepa|tos_v2_acceptance|2099-09-01' + const advisoryHostedFingerprint = 'bridge-hosted|kyc_approval|2099-12-01' + // Pre-fix localStorage can still carry blocking fingerprints. + const legacyBlockingFingerprints = ['accept-tos||due-now', 'bridge-hosted|kyc_approval|due-now'] + + it("an advisory slide's X dismisses ONLY that task — the other slide stays and the fingerprint persists", () => { + mockNextActions = [advisoryTos, advisoryHosted] + render() + + fireEvent.click(screen.getByRole('button', { name: /dismiss accept sepa terms of service/i })) + expect(screen.queryByText('Accept SEPA Terms of Service')).not.toBeInTheDocument() + expect(screen.getByText('Additional verification needed')).toBeInTheDocument() + expect(mockUpdatePreferences).toHaveBeenCalledWith('user-1', { + pendingVerificationTasksDismissed: [advisoryTosFingerprint], + }) + }) + + it('BLOCKING slides carry no X; advisory siblings on the same mount do', () => { + mockNextActions = [tosAction, advisoryHosted] + render() + + expect(screen.queryByRole('button', { name: /dismiss accept terms of service/i })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /dismiss additional verification needed/i })).toBeInTheDocument() + }) + + it('a stored (pre-fix) blocking fingerprint never hides a blocking task', () => { + mockStoredDismissal = legacyBlockingFingerprints + mockNextActions = [tosAction, hostedAction] + render() + expect(screen.getByText('Accept Terms of Service')).toBeInTheDocument() + expect(screen.getByText('Additional verification needed')).toBeInTheDocument() + }) + + it('dismissing the last remaining advisory hides the card entirely', () => { + mockStoredDismissal = [advisoryTosFingerprint] + mockNextActions = [advisoryTos, advisoryHosted] + const { container } = render() + + fireEvent.click(screen.getByRole('button', { name: /dismiss additional verification needed/i })) + expect(container).toBeEmptyDOMElement() + expect(mockUpdatePreferences).toHaveBeenCalledWith('user-1', { + pendingVerificationTasksDismissed: [advisoryTosFingerprint, advisoryHostedFingerprint], + }) + }) + + it('stored dismissed fingerprints hide only their advisories; undismissed tasks still show', () => { + mockStoredDismissal = [advisoryTosFingerprint] + mockNextActions = [advisoryTos, advisoryHosted] + render() + expect(screen.queryByText('Accept SEPA Terms of Service')).not.toBeInTheDocument() + expect(screen.getByText('Additional verification needed')).toBeInTheDocument() + }) + + it('a dismissed ADVISORY task re-surfaces when it turns blocking (same key, date gone)', () => { + // User dismissed the "complete before Sep 1" reminder in July… + mockStoredDismissal = [advisoryTosFingerprint] + // …and on Sep 1 Bridge reclassifies the same requirement as due now. + mockNextActions = [{ ...sepaTosAction, requirementKey: 'tos_v2_acceptance' }] + render() + expect(screen.getByText('Accept SEPA Terms of Service')).toBeInTheDocument() + }) + + it('a NEW requirement under the shared bridge-hosted key re-surfaces despite a dismissal', () => { + mockStoredDismissal = [advisoryHostedFingerprint] + mockNextActions = [{ ...advisoryHosted, requirementKey: 'kyc_with_proof_of_address' }] + render() + expect(screen.getByText('Additional verification needed')).toBeInTheDocument() + }) + + it('all pending advisories stored as dismissed → card hidden', () => { + mockStoredDismissal = [advisoryTosFingerprint, advisoryHostedFingerprint] + mockNextActions = [advisoryTos, advisoryHosted] + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it("a user switch does not inherit the previous user's dismissals", () => { + mockStoredDismissal = [advisoryTosFingerprint, advisoryHostedFingerprint] + mockNextActions = [advisoryTos, advisoryHosted] + const { container, rerender } = render() + expect(container).toBeEmptyDOMElement() + + // user-2 logs in on the same mount with no stored dismissals — + // user-1's in-memory keys must not hide user-2's tasks. + mockUserId = 'user-2' + mockStoredDismissal = undefined + rerender() + expect(screen.getByText('Accept SEPA Terms of Service')).toBeInTheDocument() + expect(screen.getByText('Additional verification needed')).toBeInTheDocument() + }) + + it('the non-dismissible (profile) mount ignores stored dismissals and has no X', () => { + mockStoredDismissal = [advisoryTosFingerprint, advisoryHostedFingerprint] + mockNextActions = [advisoryTos, advisoryHosted] + render() + expect(screen.getByText('Accept SEPA Terms of Service')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx index 39c8f970b4..a417b8980e 100644 --- a/src/components/Kyc/AdvisoryPreemptModal.tsx +++ b/src/components/Kyc/AdvisoryPreemptModal.tsx @@ -1,4 +1,5 @@ import ActionModal from '@/components/Global/ActionModal' +import { formatEffectiveDate } from '@/utils/format.utils' interface AdvisoryPreemptModalProps { visible: boolean @@ -9,16 +10,6 @@ interface AdvisoryPreemptModalProps { onCompleteNow: () => void } -function formatEffectiveDate(iso?: string): string | null { - if (!iso) return null - const date = new Date(iso) - // `iso` is a date-only YYYY-MM-DD, so `new Date()` parses it at UTC midnight. - // Format in UTC too, or Americas timezones render the day before the deadline. - return Number.isNaN(date.getTime()) - ? null - : date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }) -} - /** * Mandatory pre-empt for a pending Bridge verification requirement on the bank * rails. Non-closable and non-skippable: the user must complete the verification diff --git a/src/components/Profile/views/UnlockedRegions.view.tsx b/src/components/Profile/views/UnlockedRegions.view.tsx index bd9eed9483..33b30171dc 100644 --- a/src/components/Profile/views/UnlockedRegions.view.tsx +++ b/src/components/Profile/views/UnlockedRegions.view.tsx @@ -7,6 +7,7 @@ import { Icon } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' import UnlockRegionModal from '@/components/IdentityVerification/UnlockRegionModal' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import PendingVerificationTasks from '@/components/Home/PendingVerificationTasks' import { KycProcessingModal } from '@/components/Kyc/modals/KycProcessingModal' import { KycActionRequiredModal } from '@/components/Kyc/modals/KycActionRequiredModal' import { KycFailedModal } from '@/components/Kyc/modals/KycFailedModal' @@ -205,6 +206,13 @@ const UnlockedRegions = () => { Transfer to and receive from any bank account and use supported payments methods. + {/* Pending Bridge verification tasks (ToS / hosted re-verification). + Non-dismissible here — this is where the /home card's X sends + people to find their tasks again. Self-hiding when none. */} + + + + {unlockedRegions.length === 0 && ( ): NextAction => ({ + key: 'accept-tos', + kind: 'accept-tos', + purpose: 'accept-bridge-tos', + ...overrides, +}) + +describe('selectBridgeTasks', () => { + it('keeps accept-tos and bridge-hosted, drops everything else', () => { + const tasks = selectBridgeTasks([ + action({ key: 'accept-tos', kind: 'accept-tos' }), + action({ key: 'bridge-hosted', kind: 'bridge-hosted', purpose: 'bridge-additional-verification' }), + action({ key: 'sumsub:proof_of_address', kind: 'sumsub' }), + action({ key: 'wait:bridge', kind: 'wait' }), + action({ key: 'contact-support', kind: 'contact-support' }), + ]) + expect(tasks.map((t) => t.key)).toEqual(['accept-tos', 'bridge-hosted']) + }) + + it('returns [] when nothing is pending', () => { + expect(selectBridgeTasks([])).toEqual([]) + expect(selectBridgeTasks([action({ key: 'sumsub:eea_uplift', kind: 'sumsub' })])).toEqual([]) + }) + + it('passes advisory metadata (effectiveDate) through untouched', () => { + const [task] = selectBridgeTasks([ + action({ key: 'bridge-hosted', kind: 'bridge-hosted', effectiveDate: '2099-09-01' }), + ]) + expect(task.effectiveDate).toBe('2099-09-01') + }) +}) + +describe('bridgeTaskDismissalKey', () => { + it('advisory → blocking (effectiveDate disappears) changes the fingerprint', () => { + const advisory = action({ key: 'accept-tos:sepa', effectiveDate: '2099-09-01' }) + const blocking = action({ key: 'accept-tos:sepa' }) + expect(bridgeTaskDismissalKey(advisory)).not.toBe(bridgeTaskDismissalKey(blocking)) + }) + + it('a new requirement under the shared bridge-hosted key changes the fingerprint', () => { + const first = action({ key: 'bridge-hosted', kind: 'bridge-hosted', requirementKey: 'kyc_approval' }) + const second = action({ + key: 'bridge-hosted', + kind: 'bridge-hosted', + requirementKey: 'kyc_with_proof_of_address', + }) + expect(bridgeTaskDismissalKey(first)).not.toBe(bridgeTaskDismissalKey(second)) + }) + + it('an unchanged task keeps a stable fingerprint', () => { + const task = action({ + key: 'accept-tos:sepa', + effectiveDate: '2099-09-01', + requirementKey: 'tos_v2_acceptance', + }) + expect(bridgeTaskDismissalKey(task)).toBe(bridgeTaskDismissalKey({ ...task })) + }) +}) diff --git a/src/utils/bridge-tasks.utils.ts b/src/utils/bridge-tasks.utils.ts new file mode 100644 index 0000000000..4be2bdc381 --- /dev/null +++ b/src/utils/bridge-tasks.utils.ts @@ -0,0 +1,31 @@ +import type { NextAction } from '@/types/capabilities' + +/** + * The nextActions renderable as pending Bridge verification tasks: + * `accept-tos` (blocking, rail-attached — or advisory orphan) and + * `bridge-hosted` (the hosted-flow catch-all). One filter catches both the + * blocking and the advisory (future-dated, `effectiveDate`-carrying) + * populations — advisory actions arrive as orphans no rail references, so + * reading top-level `nextActions` is the only way to see them. + */ +export function selectBridgeTasks(nextActions: NextAction[]): NextAction[] { + return nextActions.filter((action) => action.kind === 'accept-tos' || action.kind === 'bridge-hosted') +} + +/** + * Fingerprint a task for dismissal persistence. Only ADVISORY (future-dated) + * tasks are dismissible — a blocking task's fingerprint is constant over time + * (`accept-tos||due-now`), so honoring a stored one would hide a NEW + * same-variant requirement months later while the user's rails are gated; the + * card exempts blocking tasks from dismissal filtering entirely. For + * advisories the task `key` alone is NOT enough: keys stay identical when a + * NEW requirement arrives under the shared `bridge-hosted` key (only + * `requirementKey` changes) or when a new round of the same requirement gets + * a new deadline — so both fields join the fingerprint and any change + * re-surfaces the slide. The advisory→blocking escalation is covered twice: + * the date leaving changes the fingerprint AND the now-blocking task stops + * consulting dismissals at all. + */ +export function bridgeTaskDismissalKey(task: NextAction): string { + return [task.key, task.requirementKey ?? '', task.effectiveDate ?? 'due-now'].join('|') +} diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts index 90c061ba46..e2dd309039 100644 --- a/src/utils/format.utils.ts +++ b/src/utils/format.utils.ts @@ -64,3 +64,19 @@ export function shortDepositReference(reference: string | undefined): string | u export function shortDepositReference(reference: string | undefined): string | undefined { return reference?.slice(0, 10) } + +/** + * "2099-03-01" → "March 1, 2099". Capability advisory deadlines + * (`NextAction.effectiveDate`) are date-only YYYY-MM-DD strings, which + * `new Date()` parses at UTC midnight — format in UTC too, or Americas + * timezones render the day before the deadline. One formatter for every + * surface that shows the same deadline (AdvisoryPreemptModal, the pending + * verification tasks card), so the same date never renders two ways. + */ +export function formatEffectiveDate(iso?: string): string | null { + if (!iso) return null + const date = new Date(iso) + return Number.isNaN(date.getTime()) + ? null + : date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }) +} diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 33432c6301..c00a3c9d04 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -498,6 +498,12 @@ export type UserPreferences = { * Read by useHomeCarouselCTAs to apply a per-CTA cooldown before re-showing. * Legacy shape was `string[]` (permanent dismissal); both are accepted on read. */ dismissedCarouselCTAs?: string[] | Record + /** Dismissal fingerprints (`bridgeTaskDismissalKey`: key|requirement|due) + * of the pending Bridge verification tasks the user individually + * dismissed on /home. A task that turns blocking or changes substance + * gets a new fingerprint and re-surfaces; the tasks always stay + * reachable under Profile → Unlocked regions. */ + pendingVerificationTasksDismissed?: string[] } export const updateUserPreferences = (
{error}