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