From 1d8f07d0d4b21dddfac38332c4fd690cb0c4a601 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Tue, 28 Jul 2026 14:15:01 +0200 Subject: [PATCH 01/10] feat(home): pending Bridge verification tasks card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users whose Bridge customer owes 'additional verification' tasks (the hosted Persona re-verification and/or a ToS (re-)acceptance) could only see them in Bridge's dashboard — in-app there was nothing actionable: the activation card deliberately stands down for anyone who can already transact, and advisory (future-dated) tasks arrive as orphan nextActions no rail references. New self-hiding PendingVerificationTasks card on /home, sibling of ActivationCTAs (outside its stand-down on purpose). It reads top-level capability nextActions — the only surface that catches both blocking tasks and advisory orphans — and renders one row per task: - accept-tos → mounts the existing BridgeTosStep wholesale (link fetch, iframe, signedAgreementId confirm, rail await) - bridge-hosted (new kind, BE api#TBD) → exchanges the key for Bridge's hosted verification URL via start-action and opens it in the existing IframeWrapper (its Persona 'complete' postMessage handler already covers completion; refetch user on close) Old-BE tolerance: an older backend never emits bridge-hosted and only emits accept-tos for blocking users — the card simply renders less. No NEXT_PUBLIC_API_VERSION bump (additive contract). --- src/app/(mobile-ui)/home/page.tsx | 6 + src/app/actions/sumsub.ts | 25 +++ .../Home/PendingVerificationTasks.tsx | 149 ++++++++++++++++++ .../PendingVerificationTasks.test.tsx | 125 +++++++++++++++ src/types/capabilities.ts | 13 +- .../__tests__/bridge-tasks.utils.test.ts | 34 ++++ src/utils/bridge-tasks.utils.ts | 13 ++ 7 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 src/components/Home/PendingVerificationTasks.tsx create mode 100644 src/components/Home/__tests__/PendingVerificationTasks.test.tsx create mode 100644 src/utils/__tests__/bridge-tasks.utils.test.ts create mode 100644 src/utils/bridge-tasks.utils.ts diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 9174375ae6..0ae9a66adf 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,11 @@ 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. */} + {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/Home/PendingVerificationTasks.tsx b/src/components/Home/PendingVerificationTasks.tsx new file mode 100644 index 0000000000..b25790fbe8 --- /dev/null +++ b/src/components/Home/PendingVerificationTasks.tsx @@ -0,0 +1,149 @@ +'use client' + +import { useCallback, useState } from 'react' +import { startBridgeHostedVerification } from '@/app/actions/sumsub' +import { Button } from '@/components/0_Bruddle/Button' +import IframeWrapper from '@/components/Global/IframeWrapper' +import { Icon } from '@/components/Global/Icons/Icon' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import { useAuth } from '@/context/authContext' +import { useCapabilities } from '@/hooks/useCapabilities' +import type { NextAction } from '@/types/capabilities' +import { selectBridgeTasks } from '@/utils/bridge-tasks.utils' +import Card from '../Global/Card' + +const TASK_COPY = { + tosBase: { + title: 'Accept Terms of Service', + description: "Accept our payment partner's terms to keep bank transfers available.", + }, + tosSepa: { + title: 'Accept SEPA Terms of Service', + description: "Accept our payment partner's updated terms to keep EUR and GBP bank transfers available.", + }, + hosted: { + title: 'Additional verification needed', + description: 'Complete a quick verification with our payment partner to keep bank transfers available.', + }, +} as const + +function taskCopy(task: NextAction): { title: string; description: string } { + if (task.kind === 'accept-tos') { + return task.key === 'accept-tos:sepa' ? TASK_COPY.tosSepa : TASK_COPY.tosBase + } + return TASK_COPY.hosted +} + +/** "2099-03-01" → "Mar 1, 2099" — UTC so the date never shifts across timezones. */ +function formatDeadline(isoDate: string): string { + return new Date(`${isoDate}T00:00:00Z`).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }) +} + +/** + * Home card listing the user's pending Bridge verification tasks — the in-app + * mirror of Bridge's "additional verification needed" dashboard state. Reads + * top-level capability `nextActions` (NOT rail gates), so it also catches the + * advisory orphans (future-dated tasks on fully-enabled users) and sidesteps + * ActivationCTAs' can-already-transact stand-down. Renders nothing when no + * task is pending. + */ +export default function PendingVerificationTasks() { + const { nextActions } = useCapabilities() + const { fetchUser } = useAuth() + const [tosOpen, setTosOpen] = useState(false) + const [hostedUrl, setHostedUrl] = useState(null) + const [isStartingHosted, setIsStartingHosted] = useState(false) + const [error, setError] = useState(null) + + const tasks = selectBridgeTasks(nextActions) + const tosTask = tasks.find((task) => task.kind === 'accept-tos') + + const handleOpenTask = useCallback(async (task: NextAction) => { + setError(null) + if (task.kind === 'accept-tos') { + setTosOpen(true) + return + } + setIsStartingHosted(true) + const { url, error: startError } = await startBridgeHostedVerification() + setIsStartingHosted(false) + if (!url) { + setError(startError ?? 'Something went wrong. Please try again.') + return + } + setHostedUrl(url) + }, []) + + const handleHostedClose = useCallback( + (source?: 'manual' | 'completed' | 'tos_accepted') => { + 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] + ) + + if (tasks.length === 0) return null + + return ( + <> + +
+ {tasks.map((task) => { + const copy = taskCopy(task) + const isHosted = task.kind === 'bridge-hosted' + return ( +
+
+ +
+
+
{copy.title}
+
{copy.description}
+ {task.effectiveDate && ( +
+ Complete before {formatDeadline(task.effectiveDate)} +
+ )} +
+ +
+ ) + })} + {error &&

{error}

} +
+
+ + {tosTask && ( + setTosOpen(false)} + onSkip={() => setTosOpen(false)} + reasonCode={tosTask.key === 'accept-tos:sepa' ? 'bridge_tos_v2_required' : 'bridge_tos_required'} + /> + )} + + {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..9dfa6dc967 --- /dev/null +++ b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx @@ -0,0 +1,125 @@ +/** + * 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. + */ +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, []>() + +jest.mock('@/hooks/useCapabilities', () => ({ + useCapabilities: () => ({ nextActions: mockNextActions }), +})) +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ fetchUser: mockFetchUser }), +})) +jest.mock('@/app/actions/sumsub', () => ({ + startBridgeHostedVerification: () => mockStartHosted(), +})) +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 ? ( +
+ + +
+ ) : null, +})) + +const tosAction: NextAction = { key: 'accept-tos', kind: 'accept-tos', purpose: 'accept-bridge-tos' } +const hostedAction: NextAction = { + key: 'bridge-hosted', + kind: 'bridge-hosted', + purpose: 'bridge-additional-verification', + requirementKey: 'kyc_approval', +} + +describe('PendingVerificationTasks', () => { + beforeEach(() => { + mockNextActions = [] + mockFetchUser.mockReset() + mockStartHosted.mockReset() + }) + + 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 = [{ ...tosAction, key: 'accept-tos:sepa', purpose: 'accept-bridge-tos-sepa' }] + 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('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('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 an inline error instead of an iframe', 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('Action not allowed for this user')).toBeInTheDocument() + expect(screen.queryByTestId('hosted-iframe')).not.toBeInTheDocument() + }) + + it('advisory task renders its deadline', () => { + mockNextActions = [{ ...hostedAction, effectiveDate: '2099-09-01' }] + render() + expect(screen.getByText(/complete before sep 1, 2099/i)).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() + }) +}) diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index c9c77e85d3..e67c18421b 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -134,8 +134,19 @@ export interface ResolvedRail { * WebSDK so the user can verify with a different * document (used for the country-not-supported CTA * on Manteca-only rails; user has a self-fix path). + * - `bridge-hosted` — open Bridge's hosted verification flow (the + * catch-all for requirements with no native Sumsub + * mapping); exchange the key for a URL via + * startBridgeHostedVerification(). */ -export type NextActionKind = 'sumsub' | 'accept-tos' | 'wait' | 'contact-support' | 'restart-identity' | 'provide-email' +export type NextActionKind = + | 'sumsub' + | 'accept-tos' + | 'wait' + | 'contact-support' + | 'restart-identity' + | 'provide-email' + | 'bridge-hosted' export interface NextAction { key: string // stable id, referenced by RailCapability.blockingActions diff --git a/src/utils/__tests__/bridge-tasks.utils.test.ts b/src/utils/__tests__/bridge-tasks.utils.test.ts new file mode 100644 index 0000000000..fd2654c2b6 --- /dev/null +++ b/src/utils/__tests__/bridge-tasks.utils.test.ts @@ -0,0 +1,34 @@ +import { selectBridgeTasks } from '../bridge-tasks.utils' +import type { NextAction } from '@/types/capabilities' + +const action = (overrides: Partial): 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') + }) +}) diff --git a/src/utils/bridge-tasks.utils.ts b/src/utils/bridge-tasks.utils.ts new file mode 100644 index 0000000000..ecbf0e2993 --- /dev/null +++ b/src/utils/bridge-tasks.utils.ts @@ -0,0 +1,13 @@ +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') +} From a2fa6b3dc3802c493e742679936855f324b301c4 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Tue, 28 Jul 2026 14:52:30 +0200 Subject: [PATCH 02/10] fix(home): harden PendingVerificationTasks per adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Open flows are snapshotted at tap time and mounted ABOVE the self-hiding early return: the task list re-derives from the ~4s user auto-refresh, and the previous shape force-unmounted an OPEN ToS modal / hosted iframe mid-verification when the task flapped away. The card hides; the flow finishes. - Each ToS row opens the modal for ITS variant (a user can owe base AND SEPA v2 simultaneously — the single find() gave the second row the first row's copy). - IframeWrapper ignores window messages while hidden: surfaces keep a wrapper mounted after manual close (multi-phase KYC ToS), and a sibling iframe's signedAgreementId event fired BOTH handlers — double ToS confirms + phantom flow transitions. This PR adds the second ToS surface on /home that made the latent hazard reachable, so it carries the guard. - Copy is population-aware (advisory 'keep … available' vs blocking 'enable'), start-action failures show friendly copy + resync the user (a 403 means the action aged out), malformed effectiveDate renders no deadline instead of 'Invalid Date', errors clear when the task set changes, and all task buttons disable while the hosted URL fetch is in flight (no stacked modals). --- src/components/Global/IframeWrapper/index.tsx | 8 +- .../Home/PendingVerificationTasks.tsx | 194 ++++++++++-------- .../PendingVerificationTasks.test.tsx | 64 +++++- 3 files changed, 177 insertions(+), 89 deletions(-) diff --git a/src/components/Global/IframeWrapper/index.tsx b/src/components/Global/IframeWrapper/index.tsx index 363cfefedb..266978066c 100644 --- a/src/components/Global/IframeWrapper/index.tsx +++ b/src/components/Global/IframeWrapper/index.tsx @@ -96,6 +96,12 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra // track completed event from iframe and close the modal useEffect(() => { const handleMessage = (event: MessageEvent) => { + // A hidden-but-mounted wrapper must not react: 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. + if (!visible) return const data = event.data if (data?.name === 'complete' && data?.metadata?.status === 'completed') { onClose('completed') @@ -109,7 +115,7 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra window.addEventListener('message', handleMessage) return () => window.removeEventListener('message', handleMessage) - }, [onClose]) + }, [onClose, visible]) return ( (null) const [hostedUrl, setHostedUrl] = useState(null) const [isStartingHosted, setIsStartingHosted] = useState(false) const [error, setError] = useState(null) const tasks = selectBridgeTasks(nextActions) - const tosTask = tasks.find((task) => task.kind === 'accept-tos') + const taskKeys = tasks.map((task) => task.key).join(',') - const handleOpenTask = useCallback(async (task: NextAction) => { + // A new task set means the previous failure context is gone. + useEffect(() => { setError(null) - if (task.kind === 'accept-tos') { - setTosOpen(true) - return - } - setIsStartingHosted(true) - const { url, error: startError } = await startBridgeHostedVerification() - setIsStartingHosted(false) - if (!url) { - setError(startError ?? 'Something went wrong. Please try again.') - return - } - setHostedUrl(url) - }, []) + }, [taskKeys]) + + 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') => { @@ -91,55 +116,60 @@ export default function PendingVerificationTasks() { [fetchUser] ) - if (tasks.length === 0) return null + const closeTos = useCallback(() => setActiveTosTask(null), []) + + if (tasks.length === 0 && !activeTosTask && !hostedUrl) return null return ( <> - -
- {tasks.map((task) => { - const copy = taskCopy(task) - const isHosted = task.kind === 'bridge-hosted' - return ( -
-
- + {tasks.length > 0 && ( + +
+ {tasks.map((task) => { + const copy = taskCopy(task) + const isHosted = task.kind === 'bridge-hosted' + const deadline = task.effectiveDate ? formatDeadline(task.effectiveDate) : null + return ( +
+
+ +
+
+
{copy.title}
+
{copy.description}
+ {deadline && ( +
Complete before {deadline}
+ )} +
+
-
-
{copy.title}
-
{copy.description}
- {task.effectiveDate && ( -
- Complete before {formatDeadline(task.effectiveDate)} -
- )} -
- -
- ) - })} - {error &&

{error}

} -
- + ) + })} + {error &&

{error}

} +
+ + )} - {tosTask && ( + {activeTosTask && ( setTosOpen(false)} - onSkip={() => setTosOpen(false)} - reasonCode={tosTask.key === 'accept-tos:sepa' ? 'bridge_tos_v2_required' : 'bridge_tos_required'} + visible + onComplete={closeTos} + onSkip={closeTos} + reasonCode={ + activeTosTask.key === 'accept-tos:sepa' ? 'bridge_tos_v2_required' : 'bridge_tos_required' + } /> )} diff --git a/src/components/Home/__tests__/PendingVerificationTasks.test.tsx b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx index 9dfa6dc967..e7acacf2f1 100644 --- a/src/components/Home/__tests__/PendingVerificationTasks.test.tsx +++ b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx @@ -6,7 +6,8 @@ * 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. + * 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' @@ -42,6 +43,11 @@ jest.mock('@/components/Global/IframeWrapper', () => ({ })) 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', @@ -63,7 +69,7 @@ describe('PendingVerificationTasks', () => { }) it('accept-tos task opens BridgeTosStep with the variant-matched reason code', () => { - mockNextActions = [{ ...tosAction, key: 'accept-tos:sepa', purpose: 'accept-bridge-tos-sepa' }] + mockNextActions = [sepaTosAction] render() expect(screen.getByText('Accept SEPA Terms of Service')).toBeInTheDocument() @@ -71,6 +77,18 @@ describe('PendingVerificationTasks', () => { 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' }) @@ -87,6 +105,25 @@ describe('PendingVerificationTasks', () => { 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' }) @@ -100,20 +137,35 @@ describe('PendingVerificationTasks', () => { expect(mockFetchUser).not.toHaveBeenCalled() }) - it('start-action failure surfaces an inline error instead of an iframe', async () => { + 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('Action not allowed for this user')).toBeInTheDocument() + 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('advisory task renders its deadline', () => { + it('advisory task renders its deadline and keep-access copy; blocking renders enable copy', () => { mockNextActions = [{ ...hostedAction, effectiveDate: '2099-09-01' }] - render() + const { rerender } = render() expect(screen.getByText(/complete before sep 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', () => { From 6b07bf9f0c74268dca93ad5fd1d6cff078569115 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Tue, 28 Jul 2026 15:11:46 +0200 Subject: [PATCH 03/10] feat(home): dismissible tasks card, resurfaced under Unlocked regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aleks: the /home card should be closable like the carousel CTAs, without losing the tasks. The X persists the dismissal per task-key set (a DIFFERENT set of pending tasks re-shows the card); the same component mounts non-dismissibly under Profile → Unlocked regions, which is where dismissed users find their tasks again. --- src/app/(mobile-ui)/home/page.tsx | 5 ++- .../Home/PendingVerificationTasks.tsx | 45 ++++++++++++++++--- .../PendingVerificationTasks.test.tsx | 45 ++++++++++++++++++- .../Profile/views/UnlockedRegions.view.tsx | 8 ++++ src/utils/general.utils.ts | 4 ++ 5 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 0ae9a66adf..0e7a78df7b 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -218,8 +218,9 @@ export default function Home() { {/* 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. */} - + activation card deliberately stands down for. Self-hiding; + dismissible here — resurfaces under Profile → Unlocked regions. */} + {isActivated ? ( ) : ( diff --git a/src/components/Home/PendingVerificationTasks.tsx b/src/components/Home/PendingVerificationTasks.tsx index 13c3d8427d..c3ff6f7bd8 100644 --- a/src/components/Home/PendingVerificationTasks.tsx +++ b/src/components/Home/PendingVerificationTasks.tsx @@ -10,6 +10,7 @@ import { useAuth } from '@/context/authContext' import { useCapabilities } from '@/hooks/useCapabilities' import type { NextAction } from '@/types/capabilities' import { selectBridgeTasks } from '@/utils/bridge-tasks.utils' +import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import Card from '../Global/Card' function taskCopy(task: NextAction): { title: string; description: string } { @@ -65,23 +66,45 @@ function formatDeadline(isoDate: string): string | null { * user refetch (~4s auto-refresh while rails are pending), and an open * modal/iframe must survive its task disappearing mid-flow — the card hides, * the flow keeps running. + * + * `dismissible` (the /home mount): an X persists the dismissal per task-key + * set (carousel-CTA pattern) — a DIFFERENT set of pending tasks re-shows the + * card. The Profile → Unlocked regions mount is non-dismissible, so dismissed + * tasks stay reachable there. */ -export default function PendingVerificationTasks() { +export default function PendingVerificationTasks({ dismissible = false }: { dismissible?: boolean }) { const { nextActions } = useCapabilities() - const { fetchUser } = useAuth() + const { user, fetchUser } = useAuth() const [activeTosTask, setActiveTosTask] = useState(null) const [hostedUrl, setHostedUrl] = useState(null) const [isStartingHosted, setIsStartingHosted] = useState(false) const [error, setError] = useState(null) + const [dismissedKeys, setDismissedKeys] = useState(null) + const userId = user?.user?.userId const tasks = selectBridgeTasks(nextActions) - const taskKeys = tasks.map((task) => task.key).join(',') + 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 + setDismissedKeys(getUserPreferences(userId)?.pendingVerificationTasksDismissed ?? null) + }, [dismissible, userId]) + + const handleDismiss = useCallback(() => { + updateUserPreferences(userId, { pendingVerificationTasksDismissed: taskKeys }) + setDismissedKeys(taskKeys) + }, [userId, taskKeys]) + + const isDismissed = dismissible && dismissedKeys !== null && dismissedKeys === taskKeys + const handleOpenTask = useCallback( async (task: NextAction) => { setError(null) @@ -118,13 +141,23 @@ export default function PendingVerificationTasks() { const closeTos = useCallback(() => setActiveTosTask(null), []) - if (tasks.length === 0 && !activeTosTask && !hostedUrl) return null + if ((tasks.length === 0 || isDismissed) && !activeTosTask && !hostedUrl) return null return ( <> - {tasks.length > 0 && ( + {tasks.length > 0 && !isDismissed && ( -
+
+ {dismissible && ( + + )} {tasks.map((task) => { const copy = taskCopy(task) const isHosted = task.kind === 'bridge-hosted' diff --git a/src/components/Home/__tests__/PendingVerificationTasks.test.tsx b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx index e7acacf2f1..20f2570e8e 100644 --- a/src/components/Home/__tests__/PendingVerificationTasks.test.tsx +++ b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx @@ -17,12 +17,18 @@ 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 }), })) jest.mock('@/context/authContext', () => ({ - useAuth: () => ({ fetchUser: mockFetchUser }), + useAuth: () => ({ user: { user: { userId: 'user-1' } }, 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(), @@ -60,6 +66,8 @@ describe('PendingVerificationTasks', () => { mockNextActions = [] mockFetchUser.mockReset() mockStartHosted.mockReset() + mockStoredDismissal = undefined + mockUpdatePreferences.mockReset() }) it('renders nothing when no bridge task is pending', () => { @@ -174,4 +182,39 @@ describe('PendingVerificationTasks', () => { expect(screen.getByText('Accept Terms of Service')).toBeInTheDocument() expect(screen.getByText('Additional verification needed')).toBeInTheDocument() }) + + describe('dismissal (home mount)', () => { + it('dismissible mount shows an X that hides the card and persists the task-key set', () => { + mockNextActions = [tosAction, hostedAction] + render() + + fireEvent.click(screen.getByRole('button', { name: /dismiss pending verification tasks/i })) + expect(screen.queryByText('Accept Terms of Service')).not.toBeInTheDocument() + expect(mockUpdatePreferences).toHaveBeenCalledWith('user-1', { + pendingVerificationTasksDismissed: 'accept-tos,bridge-hosted', + }) + }) + + it('a stored dismissal for the SAME task set keeps the card hidden', () => { + mockStoredDismissal = 'accept-tos,bridge-hosted' + mockNextActions = [tosAction, hostedAction] + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('a DIFFERENT pending task set re-shows the card despite a stored dismissal', () => { + mockStoredDismissal = 'accept-tos' + mockNextActions = [tosAction, hostedAction] + render() + expect(screen.getByText('Additional verification needed')).toBeInTheDocument() + }) + + it('the non-dismissible (profile) mount ignores stored dismissals and has no X', () => { + mockStoredDismissal = 'accept-tos,bridge-hosted' + mockNextActions = [tosAction, hostedAction] + render() + expect(screen.getByText('Accept Terms of Service')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /dismiss pending/i })).not.toBeInTheDocument() + }) + }) }) 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 && ( + /** The sorted, comma-joined task keys of the pending Bridge verification + * tasks card the user dismissed on /home. A DIFFERENT task set re-shows + * the card; the tasks stay reachable under Profile → Unlocked regions. */ + pendingVerificationTasksDismissed?: string } export const updateUserPreferences = ( From 6f9a4c0a3947e5ef410e106b73ea2fcefc6a561b Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 29 Jul 2026 15:55:50 +0200 Subject: [PATCH 04/10] feat(home): swipe between multiple pending tasks carousel-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With ToS + hosted verification pending together the stacked card grew tall; reuse the HomeCarouselCTA embla setup so each task is its own full-width slide (identical footprint to the single-task card, dots + swipe only when there's more than one). jest.setup gains matchMedia and IntersectionObserver stubs — embla needs both at init and jsdom has neither, same gap the existing ResizeObserver stub covers. --- jest.setup.ts | 32 +++++++ .../Home/PendingVerificationTasks.tsx | 87 ++++++++++--------- 2 files changed, 79 insertions(+), 40 deletions(-) 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/components/Home/PendingVerificationTasks.tsx b/src/components/Home/PendingVerificationTasks.tsx index c3ff6f7bd8..5baccfc7e0 100644 --- a/src/components/Home/PendingVerificationTasks.tsx +++ b/src/components/Home/PendingVerificationTasks.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { startBridgeHostedVerification } from '@/app/actions/sumsub' import { Button } from '@/components/0_Bruddle/Button' +import Carousel from '@/components/Global/Carousel' import IframeWrapper from '@/components/Global/IframeWrapper' import { Icon } from '@/components/Global/Icons/Icon' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' @@ -60,7 +61,9 @@ function formatDeadline(isoDate: string): string | null { * top-level capability `nextActions` (NOT rail gates), so it also catches the * orphan actions no rail references (both blocking hosted tasks and advisory * future-dated ones) and sidesteps ActivationCTAs' can-already-transact - * stand-down. Renders nothing when no task is pending. + * stand-down. Renders nothing when no task is pending. Multiple tasks render + * as full-width horizontal carousel slides (same embla setup as + * HomeCarouselCTA); a single task looks identical to a static card. * * Open flows are SNAPSHOTTED at tap time: the task list re-derives from every * user refetch (~4s auto-refresh while rails are pending), and an open @@ -146,53 +149,57 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism return ( <> {tasks.length > 0 && !isDismissed && ( - -
- {dismissible && ( - - )} +
+ {dismissible && ( + + )} + {tasks.map((task) => { const copy = taskCopy(task) const isHosted = task.kind === 'bridge-hosted' const deadline = task.effectiveDate ? formatDeadline(task.effectiveDate) : null return ( -
-
- + +
+
+ +
+
+
{copy.title}
+
{copy.description}
+ {deadline && ( +
+ Complete before {deadline} +
+ )} +
+
-
-
{copy.title}
-
{copy.description}
- {deadline && ( -
Complete before {deadline}
- )} -
- -
+ ) })} - {error &&

{error}

} -
- +
+ {error &&

{error}

} +
)} {activeTosTask && ( From 4527482809341feb5e1caa445d81cc91ea4da3ac Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 29 Jul 2026 16:37:16 +0200 Subject: [PATCH 05/10] feat(home): dismiss tasks individually, not as a set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that tasks are separate carousel slides, one X hiding ALL of them reads as a bug — each slide gets its own X and the preference stores dismissed task keys instead of the joined set. A dismissed key stays hidden on /home until the task resolves (keys are a tiny stable vocabulary, so no pruning); the Profile mount still shows everything. --- .../Home/PendingVerificationTasks.tsx | 59 +++++++++++-------- .../PendingVerificationTasks.test.tsx | 34 +++++++---- src/utils/general.utils.ts | 2 +- 3 files changed, 58 insertions(+), 37 deletions(-) diff --git a/src/components/Home/PendingVerificationTasks.tsx b/src/components/Home/PendingVerificationTasks.tsx index 5baccfc7e0..d4615bad40 100644 --- a/src/components/Home/PendingVerificationTasks.tsx +++ b/src/components/Home/PendingVerificationTasks.tsx @@ -70,10 +70,11 @@ function formatDeadline(isoDate: string): string | null { * modal/iframe must survive its task disappearing mid-flow — the card hides, * the flow keeps running. * - * `dismissible` (the /home mount): an X persists the dismissal per task-key - * set (carousel-CTA pattern) — a DIFFERENT set of pending tasks re-shows the - * card. The Profile → Unlocked regions mount is non-dismissible, so dismissed - * tasks stay reachable there. + * `dismissible` (the /home mount): each slide carries its own X that + * dismisses ONLY that task (persisted per task key) — the other slides stay. + * A dismissed key stays hidden on /home until the task resolves; the + * Profile → Unlocked regions mount is non-dismissible, so dismissed tasks + * stay reachable there. */ export default function PendingVerificationTasks({ dismissible = false }: { dismissible?: boolean }) { const { nextActions } = useCapabilities() @@ -82,7 +83,7 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism const [hostedUrl, setHostedUrl] = useState(null) const [isStartingHosted, setIsStartingHosted] = useState(false) const [error, setError] = useState(null) - const [dismissedKeys, setDismissedKeys] = useState(null) + const [dismissedKeys, setDismissedKeys] = useState([]) const userId = user?.user?.userId const tasks = selectBridgeTasks(nextActions) @@ -98,15 +99,21 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism useEffect(() => { if (!dismissible || !userId) return - setDismissedKeys(getUserPreferences(userId)?.pendingVerificationTasksDismissed ?? null) + setDismissedKeys(getUserPreferences(userId)?.pendingVerificationTasksDismissed ?? []) }, [dismissible, userId]) - const handleDismiss = useCallback(() => { - updateUserPreferences(userId, { pendingVerificationTasksDismissed: taskKeys }) - setDismissedKeys(taskKeys) - }, [userId, taskKeys]) + const handleDismissTask = useCallback( + (taskKey: string) => { + setDismissedKeys((prev) => { + const next = [...prev, taskKey] + updateUserPreferences(userId, { pendingVerificationTasksDismissed: next }) + return next + }) + }, + [userId] + ) - const isDismissed = dismissible && dismissedKeys !== null && dismissedKeys === taskKeys + const visibleTasks = dismissible ? tasks.filter((task) => !dismissedKeys.includes(task.key)) : tasks const handleOpenTask = useCallback( async (task: NextAction) => { @@ -144,30 +151,30 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism const closeTos = useCallback(() => setActiveTosTask(null), []) - if ((tasks.length === 0 || isDismissed) && !activeTosTask && !hostedUrl) return null + if (visibleTasks.length === 0 && !activeTosTask && !hostedUrl) return null return ( <> - {tasks.length > 0 && !isDismissed && ( -
- {dismissible && ( - - )} + {visibleTasks.length > 0 && ( +
- {tasks.map((task) => { + {visibleTasks.map((task) => { const copy = taskCopy(task) const isHosted = task.kind === 'bridge-hosted' const deadline = task.effectiveDate ? formatDeadline(task.effectiveDate) : null return ( - +
+ {dismissible && ( + + )}
diff --git a/src/components/Home/__tests__/PendingVerificationTasks.test.tsx b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx index 20f2570e8e..f6b7dfec2e 100644 --- a/src/components/Home/__tests__/PendingVerificationTasks.test.tsx +++ b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx @@ -17,7 +17,7 @@ import PendingVerificationTasks from '../PendingVerificationTasks' let mockNextActions: NextAction[] = [] const mockFetchUser = jest.fn() const mockStartHosted = jest.fn, []>() -let mockStoredDismissal: string | undefined +let mockStoredDismissal: string[] | undefined const mockUpdatePreferences = jest.fn() jest.mock('@/hooks/useCapabilities', () => ({ @@ -184,37 +184,51 @@ describe('PendingVerificationTasks', () => { }) describe('dismissal (home mount)', () => { - it('dismissible mount shows an X that hides the card and persists the task-key set', () => { + it("a slide's X dismisses ONLY that task — the other slide stays and the key persists", () => { mockNextActions = [tosAction, hostedAction] render() - fireEvent.click(screen.getByRole('button', { name: /dismiss pending verification tasks/i })) + fireEvent.click(screen.getByRole('button', { name: /dismiss accept terms of service/i })) expect(screen.queryByText('Accept Terms of Service')).not.toBeInTheDocument() + expect(screen.getByText('Additional verification needed')).toBeInTheDocument() expect(mockUpdatePreferences).toHaveBeenCalledWith('user-1', { - pendingVerificationTasksDismissed: 'accept-tos,bridge-hosted', + pendingVerificationTasksDismissed: ['accept-tos'], }) }) - it('a stored dismissal for the SAME task set keeps the card hidden', () => { - mockStoredDismissal = 'accept-tos,bridge-hosted' + it('dismissing the last remaining task hides the card entirely', () => { + mockStoredDismissal = ['accept-tos'] mockNextActions = [tosAction, hostedAction] const { container } = render() + + fireEvent.click(screen.getByRole('button', { name: /dismiss additional verification needed/i })) expect(container).toBeEmptyDOMElement() + expect(mockUpdatePreferences).toHaveBeenCalledWith('user-1', { + pendingVerificationTasksDismissed: ['accept-tos', 'bridge-hosted'], + }) }) - it('a DIFFERENT pending task set re-shows the card despite a stored dismissal', () => { - mockStoredDismissal = 'accept-tos' + it('stored dismissed keys hide only their tasks; undismissed tasks still show', () => { + mockStoredDismissal = ['accept-tos'] mockNextActions = [tosAction, hostedAction] render() + expect(screen.queryByText('Accept Terms of Service')).not.toBeInTheDocument() expect(screen.getByText('Additional verification needed')).toBeInTheDocument() }) + it('all pending tasks stored as dismissed → card hidden', () => { + mockStoredDismissal = ['accept-tos', 'bridge-hosted'] + mockNextActions = [tosAction, hostedAction] + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + it('the non-dismissible (profile) mount ignores stored dismissals and has no X', () => { - mockStoredDismissal = 'accept-tos,bridge-hosted' + mockStoredDismissal = ['accept-tos', 'bridge-hosted'] mockNextActions = [tosAction, hostedAction] render() expect(screen.getByText('Accept Terms of Service')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /dismiss pending/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument() }) }) }) diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 5d17444c2f..6da0b1e6b0 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -501,7 +501,7 @@ export type UserPreferences = { /** The sorted, comma-joined task keys of the pending Bridge verification * tasks card the user dismissed on /home. A DIFFERENT task set re-shows * the card; the tasks stay reachable under Profile → Unlocked regions. */ - pendingVerificationTasksDismissed?: string + pendingVerificationTasksDismissed?: string[] } export const updateUserPreferences = ( From 6dd0d66403487f68b533a1b1d40e6e6cadf65c4f Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 29 Jul 2026 16:48:01 +0200 Subject: [PATCH 06/10] docs: pendingVerificationTasksDismissed comment matches the per-key shape CodeRabbit: the JSDoc still described the pre-carousel comma-joined set string. --- src/utils/general.utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 6da0b1e6b0..e3798407e3 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -498,9 +498,9 @@ 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 - /** The sorted, comma-joined task keys of the pending Bridge verification - * tasks card the user dismissed on /home. A DIFFERENT task set re-shows - * the card; the tasks stay reachable under Profile → Unlocked regions. */ + /** Task keys of the pending Bridge verification tasks the user + * individually dismissed on /home. A dismissed key stays hidden until its + * task resolves; the tasks stay reachable under Profile → Unlocked regions. */ pendingVerificationTasksDismissed?: string[] } From 4a65c4cd310d82234d60632d36fc53ac5e8045dc Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 30 Jul 2026 17:46:00 +0200 Subject: [PATCH 07/10] fix: dismissals don't survive escalation; embedded ToS doesn't kill the hosted flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (Jota, automated high-effort pass) found two holes: 1. Dismissal persisted by task KEY alone — keys don't change when an advisory task turns blocking (effectiveDate passes) or a new Bridge requirement lands under the shared bridge-hosted key, so a user who dismissed a 'complete before Sep 1' reminder saw NOTHING when their transfers actually broke. Dismissals now persist a key|requirement|due fingerprint; any escalation or substance change re-surfaces the slide. 2. Bridge's hosted kyc_link flow can open with an embedded ToS step — exactly for this cohort, which owes both. Its signedAgreementId postMessage maps to onClose('tos_accepted'), which closed the iframe mid-verification with no refetch. tos_accepted now keeps the hosted iframe open (and syncs the acceptance); only completed/manual close. --- .../Home/PendingVerificationTasks.tsx | 31 ++++++++--- .../PendingVerificationTasks.test.tsx | 55 ++++++++++++++++--- .../__tests__/bridge-tasks.utils.test.ts | 29 +++++++++- src/utils/bridge-tasks.utils.ts | 13 +++++ src/utils/general.utils.ts | 8 ++- 5 files changed, 115 insertions(+), 21 deletions(-) diff --git a/src/components/Home/PendingVerificationTasks.tsx b/src/components/Home/PendingVerificationTasks.tsx index d4615bad40..9c2ae638a1 100644 --- a/src/components/Home/PendingVerificationTasks.tsx +++ b/src/components/Home/PendingVerificationTasks.tsx @@ -10,7 +10,7 @@ import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useAuth } from '@/context/authContext' import { useCapabilities } from '@/hooks/useCapabilities' import type { NextAction } from '@/types/capabilities' -import { selectBridgeTasks } from '@/utils/bridge-tasks.utils' +import { bridgeTaskDismissalKey, selectBridgeTasks } from '@/utils/bridge-tasks.utils' import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import Card from '../Global/Card' @@ -71,10 +71,11 @@ function formatDeadline(isoDate: string): string | null { * the flow keeps running. * * `dismissible` (the /home mount): each slide carries its own X that - * dismisses ONLY that task (persisted per task key) — the other slides stay. - * A dismissed key stays hidden on /home until the task resolves; the - * Profile → Unlocked regions mount is non-dismissible, so dismissed tasks - * stay reachable there. + * dismisses ONLY that task — the other slides stay. Dismissals persist per + * task FINGERPRINT (key + requirement + due state, see + * bridgeTaskDismissalKey), so a task that turns blocking or changes substance + * re-surfaces despite an old dismissal; the Profile → Unlocked regions mount + * is non-dismissible, so dismissed tasks stay reachable there. */ export default function PendingVerificationTasks({ dismissible = false }: { dismissible?: boolean }) { const { nextActions } = useCapabilities() @@ -103,9 +104,9 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism }, [dismissible, userId]) const handleDismissTask = useCallback( - (taskKey: string) => { + (task: NextAction) => { setDismissedKeys((prev) => { - const next = [...prev, taskKey] + const next = [...prev, bridgeTaskDismissalKey(task)] updateUserPreferences(userId, { pendingVerificationTasksDismissed: next }) return next }) @@ -113,7 +114,9 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism [userId] ) - const visibleTasks = dismissible ? tasks.filter((task) => !dismissedKeys.includes(task.key)) : tasks + const visibleTasks = dismissible + ? tasks.filter((task) => !dismissedKeys.includes(bridgeTaskDismissalKey(task))) + : tasks const handleOpenTask = useCallback( async (task: NextAction) => { @@ -139,6 +142,16 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism 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 sync the + // acceptance; only 'completed' / 'manual' actually close. + if (source === 'tos_accepted') { + void fetchUser() + return + } setHostedUrl(null) if (source === 'completed') { // Bridge re-checks the customer asynchronously — refresh so the @@ -169,7 +182,7 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism +
) : null, })) @@ -157,6 +158,25 @@ describe('PendingVerificationTasks', () => { expect(mockFetchUser).toHaveBeenCalledTimes(1) }) + it('an EMBEDDED ToS step inside the hosted flow does NOT close the iframe (mid-flow progress)', 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'). + fireEvent.click(screen.getByText('accept-embedded-tos')) + expect(screen.getByTestId('hosted-iframe')).toBeInTheDocument() + await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)) + + // The user then finishes the identity steps — completion still closes. + fireEvent.click(screen.getByText('finish')) + expect(screen.queryByTestId('hosted-iframe')).not.toBeInTheDocument() + }) + it('advisory task renders its deadline and keep-access copy; blocking renders enable copy', () => { mockNextActions = [{ ...hostedAction, effectiveDate: '2099-09-01' }] const { rerender } = render() @@ -184,7 +204,10 @@ describe('PendingVerificationTasks', () => { }) describe('dismissal (home mount)', () => { - it("a slide's X dismisses ONLY that task — the other slide stays and the key persists", () => { + const tosFingerprint = 'accept-tos||due-now' + const hostedFingerprint = 'bridge-hosted|kyc_approval|due-now' + + it("a slide's X dismisses ONLY that task — the other slide stays and the fingerprint persists", () => { mockNextActions = [tosAction, hostedAction] render() @@ -192,39 +215,55 @@ describe('PendingVerificationTasks', () => { expect(screen.queryByText('Accept Terms of Service')).not.toBeInTheDocument() expect(screen.getByText('Additional verification needed')).toBeInTheDocument() expect(mockUpdatePreferences).toHaveBeenCalledWith('user-1', { - pendingVerificationTasksDismissed: ['accept-tos'], + pendingVerificationTasksDismissed: [tosFingerprint], }) }) it('dismissing the last remaining task hides the card entirely', () => { - mockStoredDismissal = ['accept-tos'] + mockStoredDismissal = [tosFingerprint] mockNextActions = [tosAction, hostedAction] const { container } = render() fireEvent.click(screen.getByRole('button', { name: /dismiss additional verification needed/i })) expect(container).toBeEmptyDOMElement() expect(mockUpdatePreferences).toHaveBeenCalledWith('user-1', { - pendingVerificationTasksDismissed: ['accept-tos', 'bridge-hosted'], + pendingVerificationTasksDismissed: [tosFingerprint, hostedFingerprint], }) }) - it('stored dismissed keys hide only their tasks; undismissed tasks still show', () => { - mockStoredDismissal = ['accept-tos'] + it('stored dismissed fingerprints hide only their tasks; undismissed tasks still show', () => { + mockStoredDismissal = [tosFingerprint] mockNextActions = [tosAction, hostedAction] render() expect(screen.queryByText('Accept 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 = ['accept-tos:sepa|tos_v2_acceptance|2099-09-01'] + // …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 = [hostedFingerprint] + mockNextActions = [{ ...hostedAction, requirementKey: 'kyc_with_proof_of_address' }] + render() + expect(screen.getByText('Additional verification needed')).toBeInTheDocument() + }) + it('all pending tasks stored as dismissed → card hidden', () => { - mockStoredDismissal = ['accept-tos', 'bridge-hosted'] + mockStoredDismissal = [tosFingerprint, hostedFingerprint] mockNextActions = [tosAction, hostedAction] const { container } = render() expect(container).toBeEmptyDOMElement() }) it('the non-dismissible (profile) mount ignores stored dismissals and has no X', () => { - mockStoredDismissal = ['accept-tos', 'bridge-hosted'] + mockStoredDismissal = [tosFingerprint, hostedFingerprint] mockNextActions = [tosAction, hostedAction] render() expect(screen.getByText('Accept Terms of Service')).toBeInTheDocument() diff --git a/src/utils/__tests__/bridge-tasks.utils.test.ts b/src/utils/__tests__/bridge-tasks.utils.test.ts index fd2654c2b6..7959d87e9e 100644 --- a/src/utils/__tests__/bridge-tasks.utils.test.ts +++ b/src/utils/__tests__/bridge-tasks.utils.test.ts @@ -1,4 +1,4 @@ -import { selectBridgeTasks } from '../bridge-tasks.utils' +import { bridgeTaskDismissalKey, selectBridgeTasks } from '../bridge-tasks.utils' import type { NextAction } from '@/types/capabilities' const action = (overrides: Partial): NextAction => ({ @@ -32,3 +32,30 @@ describe('selectBridgeTasks', () => { 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 index ecbf0e2993..42737bc291 100644 --- a/src/utils/bridge-tasks.utils.ts +++ b/src/utils/bridge-tasks.utils.ts @@ -11,3 +11,16 @@ import type { NextAction } from '@/types/capabilities' export function selectBridgeTasks(nextActions: NextAction[]): NextAction[] { return nextActions.filter((action) => action.kind === 'accept-tos' || action.kind === 'bridge-hosted') } + +/** + * Fingerprint a task for dismissal persistence. The task `key` alone is NOT + * enough: keys stay identical when an advisory task turns blocking (its + * `effectiveDate` passes and disappears) and when a NEW Bridge requirement + * arrives under the shared `bridge-hosted` key (only `requirementKey` + * changes). A dismissal must not survive either — the user dismissed a + * "due later" reminder, not the failure of their live bank transfers — so + * both fields join the fingerprint and any change re-surfaces the slide. + */ +export function bridgeTaskDismissalKey(task: NextAction): string { + return [task.key, task.requirementKey ?? '', task.effectiveDate ?? 'due-now'].join('|') +} diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index e3798407e3..c00a3c9d04 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -498,9 +498,11 @@ 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 - /** Task keys of the pending Bridge verification tasks the user - * individually dismissed on /home. A dismissed key stays hidden until its - * task resolves; the tasks stay reachable under Profile → Unlocked regions. */ + /** 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[] } From 3ae47e158e2c5c18d40d0876a4a7fb20a3516b52 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 30 Jul 2026 17:53:12 +0200 Subject: [PATCH 08/10] fix(home): hold dismissible mount until stored dismissals hydrate CodeRabbit: dismissedKeys loads in a post-render effect (localStorage is SSR-unreadable), so the first paint briefly showed tasks the user had already dismissed. null now means not-yet-hydrated and the dismissible mount renders nothing until the effect lands. --- src/components/Home/PendingVerificationTasks.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/Home/PendingVerificationTasks.tsx b/src/components/Home/PendingVerificationTasks.tsx index 9c2ae638a1..ea3807e808 100644 --- a/src/components/Home/PendingVerificationTasks.tsx +++ b/src/components/Home/PendingVerificationTasks.tsx @@ -84,7 +84,11 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism const [hostedUrl, setHostedUrl] = useState(null) const [isStartingHosted, setIsStartingHosted] = useState(false) const [error, setError] = useState(null) - const [dismissedKeys, setDismissedKeys] = useState([]) + // null = stored dismissals not yet hydrated (localStorage is unreadable + // during SSR, hence the post-render effect). The dismissible mount must + // not paint until then — rendering with an empty list would flash tasks + // the user already dismissed. + const [dismissedKeys, setDismissedKeys] = useState(null) const userId = user?.user?.userId const tasks = selectBridgeTasks(nextActions) @@ -106,7 +110,7 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism const handleDismissTask = useCallback( (task: NextAction) => { setDismissedKeys((prev) => { - const next = [...prev, bridgeTaskDismissalKey(task)] + const next = [...(prev ?? []), bridgeTaskDismissalKey(task)] updateUserPreferences(userId, { pendingVerificationTasksDismissed: next }) return next }) @@ -114,9 +118,11 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism [userId] ) - const visibleTasks = dismissible - ? tasks.filter((task) => !dismissedKeys.includes(bridgeTaskDismissalKey(task))) - : tasks + const visibleTasks = !dismissible + ? tasks + : dismissedKeys === null + ? [] // hold the first paint until stored dismissals hydrate + : tasks.filter((task) => !dismissedKeys.includes(bridgeTaskDismissalKey(task))) const handleOpenTask = useCallback( async (task: NextAction) => { From 6695a02a5b5e6642799014ae77a18a7b50427ee1 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 30 Jul 2026 18:01:04 +0200 Subject: [PATCH 09/10] =?UTF-8?q?fix(home):=20dismissal=20state=20is=20per?= =?UTF-8?q?-user=20=E2=80=94=20no=20leak=20across=20logout/login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit: dismissedKeys stayed in memory when userId changed, so the next user inherited the previous user's hidden tasks for the render before the effect reloaded their preferences. Stored dismissals are now tagged with the user they were loaded for; a mismatched tag counts as not-hydrated and keeps the card held. --- .../Home/PendingVerificationTasks.tsx | 28 +++++++++++++------ .../PendingVerificationTasks.test.tsx | 19 ++++++++++++- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/components/Home/PendingVerificationTasks.tsx b/src/components/Home/PendingVerificationTasks.tsx index ea3807e808..4c6c633dd5 100644 --- a/src/components/Home/PendingVerificationTasks.tsx +++ b/src/components/Home/PendingVerificationTasks.tsx @@ -84,11 +84,13 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism const [hostedUrl, setHostedUrl] = useState(null) const [isStartingHosted, setIsStartingHosted] = useState(false) const [error, setError] = useState(null) - // null = stored dismissals not yet hydrated (localStorage is unreadable - // during SSR, hence the post-render effect). The dismissible mount must - // not paint until then — rendering with an empty list would flash tasks - // the user already dismissed. - const [dismissedKeys, setDismissedKeys] = 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) @@ -104,15 +106,23 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism useEffect(() => { if (!dismissible || !userId) return - setDismissedKeys(getUserPreferences(userId)?.pendingVerificationTasksDismissed ?? []) + 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) => { - setDismissedKeys((prev) => { - const next = [...(prev ?? []), bridgeTaskDismissalKey(task)] + if (!userId) return + setStoredDismissals((prev) => { + const keys = prev && prev.forUserId === userId ? prev.keys : [] + const next = [...keys, bridgeTaskDismissalKey(task)] updateUserPreferences(userId, { pendingVerificationTasksDismissed: next }) - return next + return { forUserId: userId, keys: next } }) }, [userId] diff --git a/src/components/Home/__tests__/PendingVerificationTasks.test.tsx b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx index e6e2b22315..dda586a54d 100644 --- a/src/components/Home/__tests__/PendingVerificationTasks.test.tsx +++ b/src/components/Home/__tests__/PendingVerificationTasks.test.tsx @@ -23,8 +23,9 @@ const mockUpdatePreferences = jest.fn() jest.mock('@/hooks/useCapabilities', () => ({ useCapabilities: () => ({ nextActions: mockNextActions }), })) +let mockUserId = 'user-1' jest.mock('@/context/authContext', () => ({ - useAuth: () => ({ user: { user: { userId: 'user-1' } }, fetchUser: mockFetchUser }), + useAuth: () => ({ user: { user: { userId: mockUserId } }, fetchUser: mockFetchUser }), })) jest.mock('@/utils/general.utils', () => ({ getUserPreferences: () => ({ pendingVerificationTasksDismissed: mockStoredDismissal }), @@ -69,6 +70,7 @@ describe('PendingVerificationTasks', () => { mockStartHosted.mockReset() mockStoredDismissal = undefined mockUpdatePreferences.mockReset() + mockUserId = 'user-1' }) it('renders nothing when no bridge task is pending', () => { @@ -262,6 +264,21 @@ describe('PendingVerificationTasks', () => { expect(container).toBeEmptyDOMElement() }) + it("a user switch does not inherit the previous user's dismissals", () => { + mockStoredDismissal = [tosFingerprint, hostedFingerprint] + mockNextActions = [tosAction, hostedAction] + 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 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 = [tosFingerprint, hostedFingerprint] mockNextActions = [tosAction, hostedAction] From 4a613a07712910b04b5ca0e16b16c08b3b98c2aa Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Tue, 4 Aug 2026 19:35:21 +0200 Subject: [PATCH 10/10] fix: confirm hosted-flow ToS to the BE; blocking tasks never dismissible; source-matched iframe messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /code-review (high) 2026-08-04: Finding 1 (CONFIRMED): handleHostedClose('tos_accepted') only refetched the user — the resolver is pure, so the embedded acceptance was never recorded until a Bridge webhook landed, and tapping the still-visible ToS card 409'd into 'Could not load terms'. The hosted path now runs confirmBridgeTosAndAwaitRails (the same canonical confirm every other ToS surface uses) with the iframe staying open; on failure it still resyncs. Finding 2 (CONFIRMED): a blocking task's dismissal fingerprint is constant over time (accept-tos||due-now), so an old dismissal would hide a NEW same-variant requirement while the user's rails are gated — and the orphan bridge-hosted task has no other surface outside Profile. There is nothing unique to fingerprint a new due-now round WITH, so the fix is one level up: blocking (due-now) tasks no longer render an X and ignore stored fingerprints (pre-fix localStorage entries included); advisory dismissal behavior is unchanged. Finding 3 (PLAUSIBLE): IframeWrapper now reacts only to messages whose source is its OWN iframe instead of gating on visible — the sibling double-confirm protection survives (now even against two concurrently VISIBLE wrappers, which the visible-guard let through and finding 1's new confirm call would have turned into a double confirm), while a completion landing as the modal hides is no longer dropped. New IframeWrapper spec pins own-handled / sibling-ignored / sourceless-ignored. Finding 4: formatDeadline duplicated AdvisoryPreemptModal's formatter with a different month style. One shared formatEffectiveDate in format.utils serves both surfaces (long month everywhere); the add-money suite's format.utils mock now spreads requireActual so partial mocks don't break on new exports. --- .../__tests__/add-money-states.test.tsx | 1 + .../__tests__/IframeWrapper.test.tsx | 68 +++++++++++ src/components/Global/IframeWrapper/index.tsx | 18 ++- .../Home/PendingVerificationTasks.tsx | 59 +++++---- .../PendingVerificationTasks.test.tsx | 113 +++++++++++++----- src/components/Kyc/AdvisoryPreemptModal.tsx | 11 +- src/utils/bridge-tasks.utils.ts | 19 +-- src/utils/format.utils.ts | 16 +++ 8 files changed, 226 insertions(+), 79 deletions(-) create mode 100644 src/components/Global/IframeWrapper/__tests__/IframeWrapper.test.tsx 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/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 266978066c..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,12 +97,16 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra // track completed event from iframe and close the modal useEffect(() => { const handleMessage = (event: MessageEvent) => { - // A hidden-but-mounted wrapper must not react: several surfaces keep - // a wrapper mounted after a manual close (e.g. the multi-phase KYC + // 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. - if (!visible) return + // 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') @@ -115,7 +120,7 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra window.addEventListener('message', handleMessage) return () => window.removeEventListener('message', handleMessage) - }, [onClose, visible]) + }, [onClose]) return (