From b22986cf7017f815287deec53b9226d153638009 Mon Sep 17 00:00:00 2001 From: Nick Campanini Date: Wed, 20 May 2026 16:30:27 -0400 Subject: [PATCH] DC-452: route Socure rejects to the off-boarding screen and add a Continue action --- .../id-proofing/off-boarding/page.test.tsx | 37 ++++---- .../login/id-proofing/off-boarding/page.tsx | 30 ++++--- .../components/doc-verify/DocVerifyPage.tsx | 4 +- .../id-proofing/OffBoardingContent.test.tsx | 86 +++++++++++++++++++ .../id-proofing/OffBoardingContent.tsx | 55 ++++++++++-- 5 files changed, 173 insertions(+), 39 deletions(-) diff --git a/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.test.tsx b/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.test.tsx index 3fc20a123..de4a1fdd0 100644 --- a/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.test.tsx +++ b/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.test.tsx @@ -36,6 +36,7 @@ const POPULATED_KEYS = new Set([ 'stepUpFailure:body', 'common:linkContactUs', 'common:back', + 'common:continue', 'dashboard:alertApplicationsTitle', 'dashboard:alertApplicationsBody', 'dashboard:alertApplicationsAction' @@ -75,6 +76,10 @@ vi.mock('@/features/auth', () => ({ data-apply-label={String(props.applyLabel)} data-back-href={String(props.backHref)} data-back-label={String(props.backLabel)} + data-body-list={String(props.bodyList)} + data-body-note={String(props.bodyNote)} + data-continue-href={String(props.continueHref)} + data-continue-label={String(props.continueLabel)} /> ) })) @@ -195,8 +200,18 @@ describe('OffBoardingPage', () => { const content = screen.getByTestId('off-boarding-content') expect(content).toHaveAttribute('data-title', 'offBoarding:title') expect(content).toHaveAttribute('data-body', 'offBoarding:body1') - expect(content).toHaveAttribute('data-apply-body', 'offBoarding:body2') - expect(content).toHaveAttribute('data-apply-label', 'offBoarding:action2') + // body2/body3 are now core body content (list + note), not the apply section + expect(content).toHaveAttribute('data-body-list', 'offBoarding:body2') + expect(content).toHaveAttribute('data-body-note', 'offBoarding:body3') + expect(content).toHaveAttribute('data-apply-body', 'undefined') + }) + + it('passes a Continue primary action to /login/id-proofing on the generic screen', () => { + renderPage({ isCoLoaded: false }) + + const content = screen.getByTestId('off-boarding-content') + expect(content).toHaveAttribute('data-continue-href', '/login/id-proofing') + expect(content).toHaveAttribute('data-continue-label', 'common:continue') }) it('uses the co-loaded off-boarding copy when the session is co-loaded', () => { @@ -279,15 +294,14 @@ describe('OffBoardingPage', () => { expect(content).toHaveAttribute('data-title', 'offBoarding:title') }) - it('renders docVerificationFailed-specific title and body when reason is docVerificationFailed', async () => { + it('routes docVerificationFailed to the generic "keep your account safe" screen with Continue', async () => { await renderPage({ reason: 'docVerificationFailed' }) const content = screen.getByTestId('off-boarding-content') - expect(content).toHaveAttribute('data-title', "We couldn't verify your identity") - expect(content).toHaveAttribute( - 'data-body', - "Your document couldn't be verified. You can try again with a different ID, or contact us if you need help." - ) + expect(content).toHaveAttribute('data-title', 'offBoarding:title') + expect(content).toHaveAttribute('data-body', 'offBoarding:body1') + expect(content).toHaveAttribute('data-continue-href', '/login/id-proofing') + expect(content).toHaveAttribute('data-continue-label', 'common:continue') }) it('uses step-up failure copy for OIDC callback errors (CO MyCO step-up)', async () => { @@ -308,13 +322,6 @@ describe('OffBoardingPage', () => { expect(content).toHaveAttribute('data-title', 'stepUpFailure:title') }) - it('forces canApply to false for docVerificationFailed regardless of query param', async () => { - await renderPage({ reason: 'docVerificationFailed', canApply: 'true' }) - - const content = screen.getByTestId('off-boarding-content') - expect(content).toHaveAttribute('data-can-apply', 'false') - }) - it('uses dashboard application-alert copy when reason is noQualifyingHousehold', async () => { await renderPage({ reason: 'noQualifyingHousehold' }) diff --git a/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.tsx b/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.tsx index e3d561921..af244d8bf 100644 --- a/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.tsx +++ b/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.tsx @@ -43,6 +43,10 @@ export default function OffBoardingPage() { let applyBody: string | undefined let applySkipBody: string | undefined let applyLabel: string | undefined + let bodyList: string[] | undefined + let bodyNote: string | undefined + let continueHref: string | undefined + let continueLabel: string | undefined if (reason === 'oidcCallbackError') { title = @@ -78,23 +82,19 @@ export default function OffBoardingPage() { applyBody = undefined applySkipBody = undefined applyLabel = undefined - } else if (reason === 'docVerificationFailed') { - title = "We couldn't verify your identity" - body = - "Your document couldn't be verified. You can try again with a different ID, or contact us if you need help." - canApply = false - contactLabel = tCommon('linkContactUs') - applyBody = undefined - applySkipBody = undefined - applyLabel = undefined } else { + // Generic "We want to keep your account safe" screen. Both inline Socure + // rejects (reason=idProofingFailed) and webhook rejects/resubmits land here. + // The accepted-ID list and skip note are core body content; the primary + // action is "Continue" (forward), with "Enter an ID number" as the back + // affordance. Both route to the form; contact lives in the global help band. title = t('title') body = t('body1') - // TODO: Use t('action1') once key is available in dc.csv contactLabel = tCommon('linkContactUs') - applyBody = t('body2', '') || undefined - applySkipBody = t('body3', '') || undefined - applyLabel = t('action2', '') || undefined + bodyList = (t('body2', '') || '').split('\n').filter(Boolean) + bodyNote = t('body3', '') || undefined + continueHref = '/login/id-proofing' + continueLabel = tCommon('continue') } return ( @@ -113,6 +113,10 @@ export default function OffBoardingPage() { applySkipBody={applySkipBody} applyLabel={applyLabel} applyHref={getApplyHref(i18n.language)} + bodyList={bodyList} + bodyNote={bodyNote} + continueHref={continueHref} + continueLabel={continueLabel} /> diff --git a/src/SEBT.Portal.Web/src/features/auth/components/doc-verify/DocVerifyPage.tsx b/src/SEBT.Portal.Web/src/features/auth/components/doc-verify/DocVerifyPage.tsx index 1d0b8a1f2..8e2e14e2a 100644 --- a/src/SEBT.Portal.Web/src/features/auth/components/doc-verify/DocVerifyPage.tsx +++ b/src/SEBT.Portal.Web/src/features/auth/components/doc-verify/DocVerifyPage.tsx @@ -180,8 +180,8 @@ export function DocVerifyPage({ contactLink }: DocVerifyPageProps) { trackEvent(AnalyticsEvents.IDV_FINAL_RESULT) clearChallengeContext() // Pass the reason via URL so the off-boarding route can render distinct - // copy (docVerificationFailed, challengeNotFound, etc). Mirrors the - // pattern IdProofingForm uses for noIdProvided. + // copy where it has a dedicated branch (e.g. noIdProvided). Socure + // rejects route to the generic "keep your account safe" screen. const params = new URLSearchParams() if (offboardingReason) { params.set('reason', offboardingReason) diff --git a/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/OffBoardingContent.test.tsx b/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/OffBoardingContent.test.tsx index ae728371b..52fad35eb 100644 --- a/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/OffBoardingContent.test.tsx +++ b/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/OffBoardingContent.test.tsx @@ -186,4 +186,90 @@ describe('OffBoardingContent', () => { expect(paragraphs).toHaveLength(1) }) }) + + describe('Continue action', () => { + const CONTINUE_PROPS = { + ...DEFAULT_PROPS, + continueHref: '/login/id-proofing', + continueLabel: 'Continue' + } + + it('renders a Continue button as the primary action when continue props are provided', () => { + render() + + const continueLink = screen.getByRole('link', { name: 'Continue' }) + expect(continueLink).toHaveAttribute('href', '/login/id-proofing') + expect(continueLink).toHaveClass('usa-button') + }) + + it('omits the Contact button when Continue is provided', () => { + render() + + expect(screen.queryByRole('link', { name: /contact us/i })).not.toBeInTheDocument() + }) + + it('keeps the "Enter an ID number" outline button alongside Continue', () => { + render() + + const backLink = screen.getByRole('link', { name: CONTINUE_PROPS.backLabel }) + expect(backLink).toHaveClass('usa-button--outline') + }) + + it('renders the Contact button (not Continue) when continue props are absent', () => { + render() + + expect(screen.queryByRole('link', { name: 'Continue' })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /contact us/i })).toBeInTheDocument() + }) + }) + + describe('Body list and note (Figma core body content)', () => { + const ID_TYPES = ['driver’s license', 'foreign passport', 'or another photo ID'] + + it('renders bodyList items as list items above the buttons', () => { + render( + + ) + + const items = screen.getAllByRole('listitem') + expect(items.map((li) => li.textContent)).toEqual(ID_TYPES) + }) + + it('renders the bodyNote paragraph when provided', () => { + const note = + 'You may be able to skip this step by going back and typing in an ID number instead.' + render( + + ) + + expect(screen.getByText(note)).toBeInTheDocument() + }) + + it('shows bodyList and bodyNote even when canApply is false', () => { + const note = 'Skip note text.' + render( + + ) + + expect(screen.getAllByRole('listitem')).toHaveLength(ID_TYPES.length) + expect(screen.getByText(note)).toBeInTheDocument() + }) + + it('renders no list when bodyList is empty or omitted', () => { + render() + + expect(screen.queryByRole('listitem')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/OffBoardingContent.tsx b/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/OffBoardingContent.tsx index 9ef1bce1f..5874dfce8 100644 --- a/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/OffBoardingContent.tsx +++ b/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/OffBoardingContent.tsx @@ -12,6 +12,19 @@ interface OffBoardingContentProps { applySkipBody?: string | undefined applyLabel?: string | undefined applyHref?: string | undefined + /** + * Items shown as a bulleted list directly under the body (e.g. accepted ID + * types). Always visible, independent of canApply. + */ + bodyList?: string[] | undefined + /** Note shown under the body list (e.g. the "you may be able to skip" line). */ + bodyNote?: string | undefined + /** + * When both are provided, a "Continue" primary button replaces the Contact + * button. Contact stays reachable via the global help section below. + */ + continueHref?: string | undefined + continueLabel?: string | undefined } export function OffBoardingContent({ @@ -25,9 +38,14 @@ export function OffBoardingContent({ applyBody, applySkipBody, applyLabel, - applyHref + applyHref, + bodyList, + bodyNote, + continueHref, + continueLabel }: OffBoardingContentProps) { const isExternalLink = contactHref.startsWith('http') + const showContinue = Boolean(continueHref && continueLabel) return ( <> @@ -40,6 +58,16 @@ export function OffBoardingContent({

{body}

+ {bodyList && bodyList.length > 0 && ( +
    + {bodyList.map((item) => ( +
  • {item}
  • + ))} +
+ )} + + {bodyNote &&

{bodyNote}

} + {canApply && (applyBody || applySkipBody || (applyLabel && applyHref)) && (