diff --git a/app/javascript/packages/document-capture/components/document-capture-not-ready.tsx b/app/javascript/packages/document-capture/components/document-capture-not-ready.tsx new file mode 100644 index 00000000000..07c5923ab46 --- /dev/null +++ b/app/javascript/packages/document-capture/components/document-capture-not-ready.tsx @@ -0,0 +1,53 @@ +import { Button } from '@18f/identity-components'; +import { useI18n } from '@18f/identity-react-i18n'; +import { useContext } from 'react'; +import FlowContext from '@18f/identity-verify-flow/context/flow-context'; +import { addSearchParams, forceRedirect, Navigate } from '@18f/identity-url'; +import { getConfigValue } from '@18f/identity-config'; +import AnalyticsContext from '../context/analytics'; +import { ServiceProviderContext } from '../context'; + +export interface DocumentCaptureNotReadyProps { + navigate?: Navigate; +} + +function DocumentCaptureNotReady({ navigate }: DocumentCaptureNotReadyProps) { + const { t } = useI18n(); + const { trackEvent } = useContext(AnalyticsContext); + const { currentStep } = useContext(FlowContext); + const { name: spName, failureToProofURL } = useContext(ServiceProviderContext); + const appName = getConfigValue('appName'); + const handleExit = () => { + trackEvent('IdV: docauth not ready link clicked'); + forceRedirect( + addSearchParams(spName ? failureToProofURL : '/account', { + step: currentStep, + location: 'not_ready', + }), + navigate, + ); + }; + + return ( + <> +

{t('doc_auth.not_ready.header')}

+

+ {spName + ? t('doc_auth.not_ready.content_sp', { + sp_name: spName, + app_name: appName, + }) + : t('doc_auth.not_ready.content_nosp', { + app_name: appName, + })} +

+ + + ); +} + +export default DocumentCaptureNotReady; diff --git a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx index 44dc1a798eb..89a3a07e50c 100644 --- a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx @@ -1,3 +1,4 @@ +import { useContext } from 'react'; import { PageHeading } from '@18f/identity-components'; import { FormStepError, @@ -10,6 +11,8 @@ import { useI18n } from '@18f/identity-react-i18n'; import UnknownError from './unknown-error'; import TipList from './tip-list'; import DocumentSideAcuantCapture from './document-side-acuant-capture'; +import DocumentCaptureNotReady from './document-capture-not-ready'; +import { FeatureFlagContext } from '../context'; interface DocumentCaptureReviewIssuesProps { isFailedDocType: boolean; @@ -43,6 +46,7 @@ function DocumentCaptureReviewIssues({ hasDismissed, }: DocumentCaptureReviewIssuesProps) { const { t } = useI18n(); + const { notReadySectionEnabled } = useContext(FeatureFlagContext); return ( <> {t('doc_auth.headings.review_issues')} @@ -78,6 +82,7 @@ function DocumentCaptureReviewIssues({ /> ))} + {notReadySectionEnabled && } ); diff --git a/app/javascript/packages/document-capture/components/documents-step.jsx b/app/javascript/packages/document-capture/components/documents-step.jsx index ccb2763f600..16db622b7be 100644 --- a/app/javascript/packages/document-capture/components/documents-step.jsx +++ b/app/javascript/packages/document-capture/components/documents-step.jsx @@ -8,6 +8,8 @@ import DocumentSideAcuantCapture from './document-side-acuant-capture'; import DeviceContext from '../context/device'; import UploadContext from '../context/upload'; import TipList from './tip-list'; +import DocumentCaptureNotReady from './document-capture-not-ready'; +import { FeatureFlagContext } from '../context'; /** * @typedef {'front'|'back'} DocumentSide @@ -43,7 +45,7 @@ function DocumentsStep({ const { isMobile } = useContext(DeviceContext); const { isLastStep } = useContext(FormStepsContext); const { flowPath } = useContext(UploadContext); - + const { notReadySectionEnabled } = useContext(FeatureFlagContext); return ( <> {flowPath === 'hybrid' && } @@ -70,7 +72,7 @@ function DocumentsStep({ /> ))} {isLastStep ? : } - + {notReadySectionEnabled && } ); diff --git a/app/javascript/packages/document-capture/context/feature-flag.tsx b/app/javascript/packages/document-capture/context/feature-flag.tsx new file mode 100644 index 00000000000..26048b8bcbd --- /dev/null +++ b/app/javascript/packages/document-capture/context/feature-flag.tsx @@ -0,0 +1,17 @@ +import { createContext } from 'react'; + +export interface FeatureFlagContextProps { + /** + * Specify whether to show the not-ready section on doc capture screen. + * Populated from backend configuration + */ + notReadySectionEnabled: boolean; +} + +const FeatureFlagContext = createContext({ + notReadySectionEnabled: false, +}); + +FeatureFlagContext.displayName = 'FeatureFlagContext'; + +export default FeatureFlagContext; diff --git a/app/javascript/packages/document-capture/context/index.ts b/app/javascript/packages/document-capture/context/index.ts index 128326c3ff3..dfbcbdf47bc 100644 --- a/app/javascript/packages/document-capture/context/index.ts +++ b/app/javascript/packages/document-capture/context/index.ts @@ -16,3 +16,4 @@ export { } from './failed-capture-attempts'; export type { DeviceContextValue } from './device'; export { default as InPersonContext } from './in-person'; +export { default as FeatureFlagContext } from './feature-flag'; diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 9d9125f96db..b50b0a5c2f6 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -10,6 +10,7 @@ import { FailedCaptureAttemptsContextProvider, MarketingSiteContextProvider, InPersonContext, + FeatureFlagContext, } from '@18f/identity-document-capture'; import { isCameraCapableMobile } from '@18f/identity-device'; import { FlowContext } from '@18f/identity-verify-flow'; @@ -33,6 +34,7 @@ interface AppRootData { exitUrl: string; idvInPersonUrl?: string; securityAndPrivacyHowItWorksUrl: string; + uiNotReadySectionEnabled: string; } const appRoot = document.getElementById('document-capture-form')!; @@ -99,6 +101,7 @@ const { inPersonOutageExpectedUpdateDate, usStatesTerritories = '', phoneWithCamera = '', + uiNotReadySectionEnabled = '', } = appRoot.dataset as DOMStringMap & AppRootData; let parsedUsStatesTerritories = []; @@ -175,6 +178,14 @@ const App = composeComponents( maxSubmissionAttemptsBeforeNativeCamera: Number(maxSubmissionAttemptsBeforeNativeCamera), }, ], + [ + FeatureFlagContext.Provider, + { + value: { + notReadySectionEnabled: String(uiNotReadySectionEnabled) === 'true', + }, + }, + ], [ DocumentCapture, { diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 170ee611e79..142f552a8e0 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -37,6 +37,7 @@ in_person_outage_expected_update_date: IdentityConfig.store.in_person_outage_expected_update_date, us_states_territories: us_states_territories, doc_auth_selfie_capture: IdentityConfig.store.doc_auth_selfie_capture, + ui_not_ready_section_enabled: IdentityConfig.store.doc_auth_not_ready_section_enabled, } %> <%= simple_form_for( :doc_auth, diff --git a/config/application.yml.default b/config/application.yml.default index 04818829f1e..a360f77bdff 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -85,6 +85,7 @@ doc_auth_error_sharpness_threshold: 40 doc_auth_max_attempts: 5 doc_auth_max_capture_attempts_before_native_camera: 3 doc_auth_max_submission_attempts_before_native_camera: 3 +doc_auth_not_ready_section_enabled: false doc_auth_selfie_capture: '{"enabled":false}' doc_auth_supported_country_codes: '["US", "GU", "VI", "AS", "MP", "PR", "USA" ,"GUM", "VIR", "ASM", "MNP", "PRI"]' doc_capture_request_valid_for_minutes: 15 @@ -380,11 +381,12 @@ development: database_worker_jobs_username: '' database_worker_jobs_host: '' database_worker_jobs_password: '' - doc_auth_selfie_capture: '{"enabled":false}' + doc_auth_selfie_capture: '{"enabled":false}' doc_auth_vendor: 'mock' doc_auth_vendor_randomize: false doc_auth_vendor_randomize_percent: 0 doc_auth_vendor_randomize_alternate_vendor: '' + doc_auth_not_ready_section_enabled: true domain_name: localhost:3000 enable_rate_limiting: false hmac_fingerprinter_key: a2c813d4dca919340866ba58063e4072adc459b767a74cf2666d5c1eef3861db26708e7437abde1755eb24f4034386b0fea1850a1cb7e56bff8fae3cc6ade96c @@ -523,7 +525,7 @@ test: doc_auth_vendor_randomize: false doc_auth_vendor_randomize_percent: 0 doc_auth_vendor_randomize_alternate_vendor: '' - doc_auth_selfie_capture: '{"enabled":false}' + doc_auth_selfie_capture: '{"enabled":false}' doc_capture_polling_enabled: false domain_name: www.example.com hmac_fingerprinter_key: a2c813d4dca919340866ba58063e4072adc459b767a74cf2666d5c1eef3861db26708e7437abde1755eb24f4034386b0fea1850a1cb7e56bff8fae3cc6ade96c diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index a20678ea2e1..191589bbb74 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -279,6 +279,15 @@ en: - 'Verify by mail: We’ll mail a letter to your home address. This takes 5 to 10 days.' welcome: 'You will need your:' + not_ready: + button_nosp: Cancel and return to your profile + button_sp: Exit %{app_name} and return to %{sp_name} + content_nosp: If you exit %{app_name} now, you will not have verified your + identity. You can return later to finish this process. + content_sp: If you exit %{app_name} now and return to %{sp_name}, you will not + have verified your identity. You can return later to finish this + process. + header: Not ready to add photos? phone_question: do_not_have: I don’t have a phone tips: diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml index ec8aac78177..8f7b03244e5 100644 --- a/config/locales/doc_auth/es.yml +++ b/config/locales/doc_auth/es.yml @@ -321,6 +321,15 @@ es: - 'Verificar por correo: Le enviaremos una carta a su domicilio. Esto tarda entre 5 y 10 días.' welcome: 'Necesitará su:' + not_ready: + button_nosp: Cancelar y volver a su perfil + button_sp: Salir de %{app_name} y volver a %{sp_name} + content_nosp: Si sale ahora de %{app_name}, no habrá verificado su identidad. + Puede volver más tarde para completar este proceso. + content_sp: Si sale ahora de %{app_name} y regresa a %{sp_name}, no habrá + verificado su identidad. Puede volver más tarde para completar este + proceso. + header: ¿No está listo para enviar las fotos? phone_question: do_not_have: No tengo teléfono tips: diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml index a3e6bb40593..06c97bf7484 100644 --- a/config/locales/doc_auth/fr.yml +++ b/config/locales/doc_auth/fr.yml @@ -333,6 +333,15 @@ fr: lettre à votre adresse personnelle. Cela prend 5 à 10 jours.' welcome: 'Vous aurez besoin de votre:' + not_ready: + button_nosp: Annuler et revenir à votre profil + button_sp: Quittez %{app_name} et retournez à %{sp_name} + content_nosp: Si vous quittez %{app_name}, votre identité n’aura pas été + vérifiée. Vous pourrez revenir plus tard pour terminer ce processus. + content_sp: Si vous quittez %{app_name} maintenant et revenez sur %{sp_name}, + votre identité n’aura pas été vérifiée. Vous pourrez revenir plus tard + pour terminer ce processus. + header: Vous n’êtes pas prêt à ajouter des photos? phone_question: do_not_have: Je n’ai pas de téléphone tips: diff --git a/lib/identity_config.rb b/lib/identity_config.rb index a9d884e4237..5e4c20eb141 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -180,6 +180,7 @@ def self.build_store(config_map) config.add(:doc_auth_error_dpi_threshold, type: :integer) config.add(:doc_auth_error_glare_threshold, type: :integer) config.add(:doc_auth_error_sharpness_threshold, type: :integer) + config.add(:doc_auth_not_ready_section_enabled, type: :boolean) config.add(:doc_auth_max_attempts, type: :integer) config.add(:doc_auth_max_capture_attempts_before_native_camera, type: :integer) config.add(:doc_auth_max_submission_attempts_before_native_camera, type: :integer) diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index eedb3fef6bc..314ca89f7e6 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -9,10 +9,12 @@ let(:user) { user_with_2fa } let(:fake_analytics) { FakeAnalytics.new } let(:sp_name) { 'Test SP' } + let(:enable_not_ready) { true } before do allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) allow_any_instance_of(ServiceProviderSession).to receive(:sp_name).and_return(sp_name) - + allow(IdentityConfig.store).to receive(:doc_auth_not_ready_section_enabled). + and_return(enable_not_ready) visit_idp_from_oidc_sp_with_ial2 sign_in_and_2fa_user(user) @@ -134,6 +136,16 @@ expect(DocAuthLog.find_by(user_id: user.id).state).to be_nil end + context 'not ready section' do + it 'renders not ready section when enabled' do + expect(page).to have_content( + I18n.t( + 'doc_auth.not_ready.content_sp', sp_name: sp_name, + app_name: APP_NAME + ), + ) + end + end end context 'standard mobile flow' do diff --git a/spec/javascript/packages/document-capture/components/document-capture-not-ready-spec.tsx b/spec/javascript/packages/document-capture/components/document-capture-not-ready-spec.tsx new file mode 100644 index 00000000000..2f8dbbcf94f --- /dev/null +++ b/spec/javascript/packages/document-capture/components/document-capture-not-ready-spec.tsx @@ -0,0 +1,127 @@ +import sinon from 'sinon'; + +import { FlowContext } from '@18f/identity-verify-flow'; +import { I18nContext } from '@18f/identity-react-i18n'; +import { I18n } from '@18f/identity-i18n'; +import userEvent from '@testing-library/user-event'; +import type { Navigate } from '@18f/identity-url'; +import { + AnalyticsContextProvider, + ServiceProviderContextProvider, +} from '@18f/identity-document-capture/context'; +import DocumentCaptureNotReady from '@18f/identity-document-capture/components/document-capture-not-ready'; +import { expect } from 'chai'; +import { render } from '../../../support/document-capture'; + +describe('DocumentCaptureNotReady', () => { + beforeEach(() => { + const config = document.createElement('script'); + config.id = 'test-config'; + config.type = 'application/json'; + config.setAttribute('data-config', ''); + config.textContent = JSON.stringify({ appName: 'Login.gov' }); + document.body.append(config); + }); + const trackEvent = sinon.spy(); + const navigateSpy: Navigate = sinon.spy(); + context('with service provider', () => { + const spName = 'testSP'; + it('renders, track event and redirect', async () => { + const { getByRole } = render( + + '', + }} + > + + + + + + + , + ); + // header + expect(getByRole('heading', { name: 'header text', level: 2 })).to.be.ok(); + + // content and exit link + const exitLink = getByRole('button', { name: 'Exit Login.gov and return to testSP' }); + expect(exitLink).to.be.ok(); + await userEvent.click(exitLink); + expect(navigateSpy).to.be.called.calledWithMatch( + /failure-to-proof\?step=document_capture&location=not_ready/, + ); + expect(trackEvent).to.be.calledWithMatch(/IdV: docauth not ready link clicked/); + }); + }); + + context('without service provider', () => { + it('renders, track event and redirect', async () => { + const { getByRole } = render( + + '', + }} + > + + + + + + + , + ); + // header + expect(getByRole('heading', { name: 'header text', level: 2 })).to.be.ok(); + + // content and exit link + const exitLink = getByRole('button', { name: 'Cancel and return to your profile' }); + expect(exitLink).to.be.ok(); + await userEvent.click(exitLink); + expect(navigateSpy).to.be.called.calledWithMatch( + /account\?step=document_capture&location=not_ready/, + ); + expect(trackEvent).to.be.calledWithMatch(/IdV: docauth not ready link clicked/); + }); + }); +}); diff --git a/spec/javascript/packages/document-capture/components/documents-step-spec.jsx b/spec/javascript/packages/document-capture/components/documents-step-spec.jsx index d160d95c5b4..44b2e570d37 100644 --- a/spec/javascript/packages/document-capture/components/documents-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/documents-step-spec.jsx @@ -1,12 +1,15 @@ import userEvent from '@testing-library/user-event'; import sinon from 'sinon'; +import { expect } from 'chai'; import { t } from '@18f/identity-i18n'; import { DeviceContext, UploadContextProvider, FailedCaptureAttemptsContextProvider, + FeatureFlagContext, } from '@18f/identity-document-capture'; import DocumentsStep from '@18f/identity-document-capture/components/documents-step'; +import { composeComponents } from '@18f/identity-compose-components'; import { render } from '../../../support/document-capture'; import { getFixtureFile } from '../../../support/file'; @@ -82,4 +85,39 @@ describe('document-capture/components/documents-step', () => { expect(queryByText(notExpectedText)).to.not.exist(); }); + + context('not ready section', () => { + it('is rendered when enabled', () => { + const App = composeComponents( + [ + FeatureFlagContext.Provider, + { + value: { + notReadySectionEnabled: true, + }, + }, + ], + [DocumentsStep], + ); + const { getByRole } = render(); + expect(getByRole('heading', { name: 'doc_auth.not_ready.header', level: 2 })).to.be.ok(); + const button = getByRole('button', { name: 'doc_auth.not_ready.button_nosp' }); + expect(button).to.be.ok(); + }); + it('is not rendered when disabled', () => { + const App = composeComponents( + [ + FeatureFlagContext.Provider, + { + value: { + notReadySectionEnabled: false, + }, + }, + ], + [DocumentsStep], + ); + const { queryByRole } = render(); + expect(queryByRole('heading', { name: 'doc_auth.not_ready.header', level: 2 })).to.be.null(); + }); + }); });