diff --git a/.codeclimate.yml b/.codeclimate.yml index 02dc06e2133..a98b2f902fe 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -4,8 +4,7 @@ checks: config: threshold: 4 complex-logic: - config: - threshold: 4 + enabled: false file-lines: enabled: false method-complexity: diff --git a/Gemfile b/Gemfile index eb0a40b9ad6..f575d1b9120 100644 --- a/Gemfile +++ b/Gemfile @@ -9,12 +9,10 @@ gem 'rails', '~> 6.1.4' @hostdata_gem ||= { github: '18F/identity-hostdata', tag: 'v3.4.0' } @logging_gem ||= { github: '18F/identity-logging', tag: 'v0.1.0' } @saml_gem ||= { github: '18F/saml_idp', tag: 'v0.14.3-18f' } -@telephony_gem ||= { github: '18f/identity-telephony', tag: 'v0.4.4' } @validations_gem ||= { github: '18F/identity-validations', tag: 'v0.7.1' } gem 'identity-hostdata', @hostdata_gem gem 'identity-logging', @logging_gem -gem 'identity-telephony', @telephony_gem gem 'identity_validations', @validations_gem gem 'saml_idp', @saml_gem @@ -22,6 +20,8 @@ gem 'ahoy_matey', '~> 3.0' gem 'autoprefixer-rails', '~> 10.0' gem 'aws-sdk-kms', '~> 1.4' gem 'aws-sdk-ses', '~> 1.6' +gem 'aws-sdk-pinpoint' +gem 'aws-sdk-pinpointsmsvoice' gem 'base32-crockford' gem 'bootsnap', '~> 1.9.0', require: false gem 'blueprinter', '~> 0.25.3' @@ -53,7 +53,7 @@ gem 'rack-timeout', require: false gem 'redacted_struct' gem 'redis', '>= 3.2.0' gem 'redis-namespace' -gem 'redis-session-store', '>= 0.11.3' +gem 'redis-session-store', github: '18f/redis-session-store', tag: 'v0.11.4-18f' gem 'retries' gem 'rotp', '~> 6.1' gem 'rqrcode' diff --git a/Gemfile-dev.example b/Gemfile-dev.example index 6d2405224fc..0ca154264e0 100644 --- a/Gemfile-dev.example +++ b/Gemfile-dev.example @@ -17,7 +17,6 @@ FileUtils.cp("Gemfile.lock", "Gemfile-dev.lock") # @hostdata_gem = { path: '../identity-hostdata' } # @idp_functions_gem = { path: '../identity-idp-functions' } # @logging_gem = { path: '../identity-logging' } -# @telephony_gem = { path: '../identity-telephony' } # @validations_gem = { path: '../identity-validations' } # @saml_gem = { path: '../saml_idp' } diff --git a/Gemfile.lock b/Gemfile.lock index 02cf956fc65..3581e056646 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -37,14 +37,13 @@ GIT uuid GIT - remote: https://github.com/18f/identity-telephony.git - revision: 15e9eb147900e959e130de1bf409ee1814e24ecc - tag: v0.4.4 + remote: https://github.com/18f/redis-session-store.git + revision: d3f5e38d3a6173737a580d774767a0d8471b1e67 + tag: v0.11.4-18f specs: - identity-telephony (0.4.4) - aws-sdk-pinpoint - aws-sdk-pinpointsmsvoice - i18n + redis-session-store (0.11.4.pre.18f) + actionpack (>= 3, < 7) + redis (>= 3, < 5) GEM remote: https://rubygems.org/ @@ -485,9 +484,6 @@ GEM redis (4.4.0) redis-namespace (1.8.1) redis (>= 3.0.4) - redis-session-store (0.11.3) - actionpack (>= 3, < 7) - redis (>= 3, < 5) regexp_parser (2.1.1) reline (0.2.5) io-console (~> 0.5) @@ -690,6 +686,8 @@ DEPENDENCIES autoprefixer-rails (~> 10.0) aws-sdk-cloudwatchlogs aws-sdk-kms (~> 1.4) + aws-sdk-pinpoint + aws-sdk-pinpointsmsvoice aws-sdk-ses (~> 1.6) axe-core-rspec (~> 4.2) base32-crockford @@ -721,7 +719,6 @@ DEPENDENCIES i18n-tasks (>= 0.9.31) identity-hostdata! identity-logging! - identity-telephony! identity_validations! irb jwt @@ -758,7 +755,7 @@ DEPENDENCIES redacted_struct redis (>= 3.2.0) redis-namespace - redis-session-store (>= 0.11.3) + redis-session-store! retries rotp (~> 6.1) rqrcode diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb index 1902b85fc4d..d28f0d68398 100644 --- a/app/controllers/concerns/idv/document_capture_concern.rb +++ b/app/controllers/concerns/idv/document_capture_concern.rb @@ -7,6 +7,8 @@ def override_document_capture_step_csp request, # required to run wasm until wasm-eval is available script_src: ['\'unsafe-eval\''], + # required because acuant styles its own elements with inline style attributes + style_src: ['\'unsafe-inline\''], # required for retrieving image dimensions from uploaded images img_src: ['blob:'], ) diff --git a/app/controllers/users/authorization_confirmation_controller.rb b/app/controllers/users/authorization_confirmation_controller.rb index 528a4ccab1a..32dbce58b26 100644 --- a/app/controllers/users/authorization_confirmation_controller.rb +++ b/app/controllers/users/authorization_confirmation_controller.rb @@ -11,6 +11,7 @@ class AuthorizationConfirmationController < ApplicationController def show analytics.track_event(Analytics::AUTHENTICATION_CONFIRMATION) @sp = ServiceProvider.find_by(issuer: sp_session[:issuer]) + @email = EmailContext.new(current_user).last_sign_in_email_address.email end def update diff --git a/app/forms/webauthn_setup_form.rb b/app/forms/webauthn_setup_form.rb index 5bb5d4c62ca..87be6a7d66c 100644 --- a/app/forms/webauthn_setup_form.rb +++ b/app/forms/webauthn_setup_form.rb @@ -64,7 +64,10 @@ def valid_attestation_response?(protocol) def safe_response(original_origin) @attestation_response.valid?(@challenge.pack('c*'), original_origin) rescue StandardError - errors.add :name, I18n.t('errors.webauthn_setup.attestation_error') + errors.add :name, I18n.t( + 'errors.webauthn_setup.attestation_error', + link: MarketingSite.contact_url, + ) false end diff --git a/app/javascript/packages/document-capture/components/acuant-capture-canvas.jsx b/app/javascript/packages/document-capture/components/acuant-capture-canvas.jsx index 421205d75db..d531250fb53 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture-canvas.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture-canvas.jsx @@ -1,19 +1,11 @@ -import { useContext, useMemo, useEffect, useRef, useState } from 'react'; +import { useContext, useEffect, useRef, useState } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; import AcuantContext from '../context/acuant'; import useAsset from '../hooks/use-asset'; -import useInstanceId from '../hooks/use-instance-id'; import useImmutableCallback from '../hooks/use-immutable-callback'; +import './acuant-capture-canvas.scss'; -/** - * @enum {string} - */ -const CaptureStatus = { - ALIGN: 'ALIGN', - MOVE_CLOSER: 'MOVE_CLOSER', - TAP_TO_CAPTURE: 'TAP_TO_CAPTURE', - CAPTURING: 'CAPTURING', -}; +/** @typedef {import('../context/acuant').AcuantJavaScriptWebSDK} AcuantJavaScriptWebSDK */ /** * @enum {number} @@ -92,13 +84,14 @@ export function defineObservableProperty(object, property, onChangeCallback) { /** * @typedef {( - * | null // Cropping failure (SDK v11.4.3, L753) - * | undefined // Cropping failure (SDK v11.4.3, L960) - * | 'Camera not supported.' // Camera not supported (SDK v11.4.3, L74, L798) - * | 'already started.' // Capture already started (SDK v11.4.3, L565) - * | 'already started' // Capture already started (SDK v11.4.3, L580) - * | 'Missing HTML elements.' // Required page elements are not available (SDK v11.4.3, L568) - * | MediaStreamError // User or system denied camera access (SDK v11.4.3, L544) + * | undefined // Cropping failure (SDK v11.5.0, L1171) + * | 'Camera not supported.' // Camera not supported (SDK v11.5.0, L978) + * | 'already started.' // Capture already started (SDK v11.5.0, L724) + * | 'Missing HTML elements.' // Required page elements are not available (SDK v11.5.0, L727) + * | Error // User or system denied camera access (SDK v11.5.0, L673) + * | "Expected div with 'acuant-camera' id" // Failure to setup due to missing element (SDK v11.5.0, L706) + * | 'Live capture has previously failed and was called again. User was sent to manual capture.' // Previous failure (SDK v11.5.0, L698) + * | 'sequence-break' // iOS 15 sequence break (SDK v11.5.0, L1327) * )} AcuantCaptureFailureError */ @@ -127,6 +120,7 @@ export function defineObservableProperty(object, property, onChangeCallback) { * @typedef AcuantCamera * * @prop {(callback: (response: AcuantImage)=>void, errorCallback: function)=>void} start + * @prop {(callback: AcuantCameraUICallbacks)=>void} startManualCapture * @prop {(callback: (response: AcuantImage)=>void)=>void} triggerCapture * @prop {( * data: string, @@ -142,6 +136,7 @@ export function defineObservableProperty(object, property, onChangeCallback) { * * @prop {AcuantCameraUI} AcuantCameraUI Acuant camera UI API. * @prop {AcuantCamera} AcuantCamera Acuant camera API. + * @prop {AcuantJavaScriptWebSDK} AcuantJavascriptWebSdk Acuant web SDK. */ /** @@ -189,7 +184,7 @@ export function defineObservableProperty(object, property, onChangeCallback) { */ /** - * @typedef {(error:AcuantCaptureFailureError)=>void} AcuantFailureCallback + * @typedef {(error?: AcuantCaptureFailureError, code?: string) => void} AcuantFailureCallback */ /** @@ -199,55 +194,6 @@ export function defineObservableProperty(object, property, onChangeCallback) { * @prop {AcuantFailureCallback} onImageCaptureFailure Failure callback. */ -/** - * Returns the computed capture status based on current capture type and frame state. - * - * @param {AcuantCaptureType} captureType Current capture type. - * @param {AcuantFrameState} frameState Current frame state. - * - * @return {CaptureStatus} - */ -function getCaptureStatus(captureType, frameState) { - // Acuant internally updates UI state to "Tap to Capture" but does _not_ invoke the - // `onFrameAvailable` callback, so we have to track `captureType` separately. - if (captureType === 'TAP' || frameState === AcuantUIState.TAP_TO_CAPTURE) { - return CaptureStatus.TAP_TO_CAPTURE; - } - - switch (frameState) { - case AcuantDocumentState.GOOD_DOCUMENT: - return CaptureStatus.CAPTURING; - case AcuantDocumentState.SMALL_DOCUMENT: - return CaptureStatus.MOVE_CLOSER; - default: - return CaptureStatus.ALIGN; - } -} - -/** - * Returns the translation key to use for the status text based on current capture status. - * - * @param {CaptureStatus} captureStatus - * - * @return {string} - */ -function getStatusLabelKey(captureStatus) { - switch (captureStatus) { - case CaptureStatus.CAPTURING: - // i18n-tasks-use t('doc_auth.accessible_labels.status_capturing') - return 'doc_auth.accessible_labels.status_capturing'; - case CaptureStatus.MOVE_CLOSER: - // i18n-tasks-use t('doc_auth.accessible_labels.status_move_closer') - return 'doc_auth.accessible_labels.status_move_closer'; - case CaptureStatus.TAP_TO_CAPTURE: - // i18n-tasks-use t('doc_auth.accessible_labels.status_tap_to_capture') - return 'doc_auth.accessible_labels.status_tap_to_capture'; - default: - // i18n-tasks-use t('doc_auth.accessible_labels.status_align') - return 'doc_auth.accessible_labels.status_align'; - } -} - /** * @param {AcuantCaptureCanvasProps} props Component props. */ @@ -258,49 +204,40 @@ function AcuantCaptureCanvas({ const { isReady } = useContext(AcuantContext); const { getAssetPath } = useAsset(); const { t } = useI18n(); - const instanceId = useInstanceId(); - const [hasCaptured, setHasCaptured] = useState(false); - const canvasRef = useRef(/** @type {(HTMLCanvasElement & {callback: function})?} */ (null)); + const cameraRef = useRef(/** @type {HTMLDivElement?} */ (null)); const onCropped = useImmutableCallback( (response) => { if (response) { onImageCaptureSuccess(response); } else { - onImageCaptureFailure(response); + onImageCaptureFailure(); } }, - [onImageCaptureSuccess, onImageCaptureFailure], + [onImageCaptureSuccess], ); const [captureType, setCaptureType] = useState(/** @type {AcuantCaptureType} */ ('AUTO')); - const [frameState, setFrameState] = useState( - /** @type {AcuantFrameState} */ (AcuantDocumentState.NO_DOCUMENT), - ); - const captureStatus = useMemo(() => getCaptureStatus(captureType, frameState), [ - captureType, - frameState, - ]); useEffect(() => { - if (canvasRef.current) { + function onAcuantCameraCreated() { + const canvas = document.getElementById('acuant-ui-canvas'); // Acuant SDK assigns a callback property to the canvas when it switches to its "Tap to // Capture" mode (Acuant SDK v11.4.4, L158). Infer capture type by presence of the property. - defineObservableProperty(canvasRef.current, 'callback', (callback) => { + defineObservableProperty(canvas, 'callback', (callback) => { setCaptureType(callback ? 'TAP' : 'AUTO'); }); } + + cameraRef.current?.addEventListener('acuantcameracreated', onAcuantCameraCreated); + return () => { + cameraRef.current?.removeEventListener('acuantcameracreated', onAcuantCameraCreated); + }; }, []); useEffect(() => { if (isReady) { - setHasCaptured(false); /** @type {AcuantGlobal} */ (window).AcuantCameraUI.start( { - onFrameAvailable(result) { - setFrameState(result.state); - }, - onCaptured() { - setHasCaptured(true); - }, + onCaptured() {}, onCropped, }, onImageCaptureFailure, @@ -323,8 +260,7 @@ function AcuantCaptureCanvas({ }; }, [isReady]); - // The video element is never visible to the user, but it needs to be present - // in the DOM for the Acuant SDK to capture the feed from the camera. + const clickCanvas = () => document.getElementById('acuant-ui-canvas')?.click(); return ( <> @@ -338,47 +274,24 @@ function AcuantCaptureCanvas({ alt="" width="144" height="144" - style={{ - position: 'absolute', - left: '50%', - top: '50%', - transform: 'translate(-72px, -72px)', - }} + className="acuant-capture-canvas__spinner" /> )} - {/* eslint-disable-next-line jsx-a11y/media-has-caption */} - -
+ {t('doc_auth.accessible_labels.camera_video_capture_instructions')} +
+ )} + + > ); } diff --git a/app/javascript/packages/document-capture/components/acuant-capture-canvas.scss b/app/javascript/packages/document-capture/components/acuant-capture-canvas.scss new file mode 100644 index 00000000000..adeae8ab764 --- /dev/null +++ b/app/javascript/packages/document-capture/components/acuant-capture-canvas.scss @@ -0,0 +1,19 @@ +.acuant-capture-canvas__spinner { + left: 50%; + position: absolute; + top: 50%; + transform: translate(-72px, -72px); +} + +.acuant-capture-canvas__camera { + left: 50%; + max-width: 100%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 100%; + + @media (orientation: landscape) { + width: fit-content; + } +} diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index 08561a65d8a..87452f37c65 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -19,12 +19,14 @@ import DeviceContext from '../context/device'; import UploadContext from '../context/upload'; import useIfStillMounted from '../hooks/use-if-still-mounted'; import useCounter from '../hooks/use-counter'; +import useCookie from '../hooks/use-cookie'; import './acuant-capture.scss'; /** @typedef {import('react').ReactNode} ReactNode */ /** @typedef {import('./acuant-capture-canvas').AcuantSuccessResponse} AcuantSuccessResponse */ /** @typedef {import('./acuant-capture-canvas').AcuantDocumentType} AcuantDocumentType */ /** @typedef {import('./full-screen').FullScreenRefHandle} FullScreenRefHandle */ +/** @typedef {import('../context/acuant').AcuantGlobal} AcuantGlobal */ /** * @typedef {"id"|"passport"|"none"} AcuantDocumentTypeLabel @@ -68,22 +70,6 @@ import './acuant-capture.scss'; * @typedef {ImageAnalyticsPayload & _AcuantImageAnalyticsPayload} AcuantImageAnalyticsPayload */ -/** - * @typedef AcuantPassiveLiveness - * - * @prop {(callback:(nextImageData:string)=>void)=>void} startSelfieCapture Start liveness capture. - */ - -/** - * @typedef AcuantGlobals - * - * @prop {AcuantPassiveLiveness} AcuantPassiveLiveness Acuant Passive Liveness API. - */ - -/** - * @typedef {typeof window & AcuantGlobals} AcuantGlobal - */ - /** * @typedef AcuantCaptureProps * @@ -95,14 +81,22 @@ import './acuant-capture.scss'; * metadata?: ImageAnalyticsPayload * )=>void} onChange Callback receiving next value on change. * @prop {()=>void=} onCameraAccessDeclined Camera permission declined callback. - * @prop {'user'=} capture Facing mode of capture. If capture is not specified and a camera is - * supported, defaults to the Acuant environment camera capture. + * @prop {'user'|'environment'=} capture Facing mode of capture. If capture is not specified and a + * camera is supported, defaults to the Acuant environment camera capture. * @prop {string=} className Optional additional class names. * @prop {boolean=} allowUpload Whether to allow file upload. Defaults to `true`. * @prop {ReactNode=} errorMessage Error to show. * @prop {string} name Prefix to prepend to user action analytics labels. */ +/** + * Non-breaking space (` `) represented as unicode escape sequence, which React will more + * happily tolerate than an HTML entity. + * + * @type {string} + */ +const NBSP_UNICODE = '\u00A0'; + /** * A noop function. */ @@ -138,14 +132,28 @@ function getDocumentTypeLabel(documentType) { /** * @param {import('./acuant-capture-canvas').AcuantCaptureFailureError} error + * @param {string=} code * * @return {string} */ -export function getNormalizedAcuantCaptureFailureMessage(error) { +export function getNormalizedAcuantCaptureFailureMessage(error, code) { if (isAcuantCameraAccessFailure(error)) { return 'User or system denied camera access'; } + const { + REPEAT_FAIL_CODE, + SEQUENCE_BREAK_CODE, + } = /** @type {AcuantGlobal} */ (window).AcuantJavascriptWebSdk; + + switch (code) { + case REPEAT_FAIL_CODE: + return 'Capture started after failure already occurred (REPEAT_FAIL_CODE)'; + case SEQUENCE_BREAK_CODE: + return 'iOS 15 GPU Highwater failure (SEQUENCE_BREAK_CODE)'; + default: + } + if (!error) { return 'Cropping failure'; } @@ -154,9 +162,9 @@ export function getNormalizedAcuantCaptureFailureMessage(error) { case 'Camera not supported.': return 'Camera not supported'; case 'Missing HTML elements.': + case "Expected div with 'acuant-camera' id": return 'Required page elements are not available'; case 'already started.': - case 'already started': return 'Capture already started'; default: return 'Unknown error'; @@ -273,6 +281,7 @@ function AcuantCapture( const { isMobile } = useContext(DeviceContext); const { t, formatHTML } = useI18n(); const [attempt, incrementAttempt] = useCounter(1); + const [acuantFailureCookie, setAcuantFailureCookie] = useCookie('AcuantCameraHasFailed'); const { onFailedCaptureAttempt, onResetFailedCaptureAttempts } = useContext( FailedCaptureAttemptsContext, ); @@ -385,12 +394,13 @@ function AcuantCapture( */ function startCaptureOrTriggerUpload(event) { if (event.target === inputRef.current) { - const shouldStartEnvironmentCapture = - hasCapture && capture !== 'user' && !isForceUploading.current; + const isAcuantCaptureCapable = hasCapture && !acuantFailureCookie; + const shouldStartAcuantCapture = + isAcuantCaptureCapable && capture !== 'user' && !isForceUploading.current; const shouldStartSelfieCapture = isAcuantLoaded && capture === 'user' && !isForceUploading.current; - if (!allowUpload || shouldStartSelfieCapture || shouldStartEnvironmentCapture) { + if (!allowUpload || shouldStartSelfieCapture || shouldStartAcuantCapture) { event.preventDefault(); } @@ -401,7 +411,7 @@ function AcuantCapture( onChangeAndResetError(dataURI); }), ); - } else if (shouldStartEnvironmentCapture) { + } else if (shouldStartAcuantCapture) { setIsCapturingEnvironment(true); } @@ -502,13 +512,28 @@ function AcuantCapture( >