diff --git a/.eslintrc b/.eslintrc index 1a97ef3f66b..dfd27f79c7f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -20,6 +20,7 @@ "rules": { "prettier/prettier": "error", "func-names": 0, + "function-paren-newline": "off", "prefer-arrow-callback": 0, "import/prefer-default-export": "off", "import/extensions": ["off", "never"], diff --git a/Gemfile b/Gemfile index 9df9bbee383..0685c145bdd 100644 --- a/Gemfile +++ b/Gemfile @@ -21,7 +21,7 @@ gem 'hiredis' gem 'http_accept_language' gem 'httparty' gem 'identity-hostdata', github: '18F/identity-hostdata', tag: 'v0.4.1' -gem 'identity-telephony', github: '18f/identity-telephony', tag: 'v0.1.4' +gem 'identity-telephony', github: '18f/identity-telephony', tag: 'v0.1.5' gem 'identity_validations', github: '18F/identity-validations', branch: 'master' gem 'json-jwt', '>= 1.11.0' gem 'local_time' @@ -60,7 +60,6 @@ gem 'strong_migrations', '>= 0.4.2' gem 'subprocess', require: false gem 'twilio-ruby' gem 'two_factor_authentication', '>= 2.1.1' -gem 'typhoeus' gem 'uglifier', '~> 3.2' gem 'user_agent_parser' gem 'valid_email', '>= 0.1.3' @@ -120,6 +119,6 @@ group :test do end group :production do - gem 'aamva', github: '18F/identity-aamva-api-client-gem', tag: 'v3.4.0' - gem 'lexisnexis', github: '18F/identity-lexisnexis-api-client-gem', tag: 'v1.2.0' + gem 'aamva', github: '18F/identity-aamva-api-client-gem', tag: 'v3.4.1' + gem 'lexisnexis', github: '18F/identity-lexisnexis-api-client-gem', tag: 'v2.1.0' end diff --git a/Gemfile.lock b/Gemfile.lock index a366d38daa3..bbd5b3f0b83 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,14 +1,13 @@ GIT remote: https://github.com/18F/identity-aamva-api-client-gem.git - revision: f88d3cda9646aaae7f21f5bd37621618743dd88e - tag: v3.4.0 + revision: 149b5b480f0319ec39410e497bb4bbffd1652014 + tag: v3.4.1 specs: - aamva (3.4.0) + aamva (3.4.1) dotenv faraday hashie retries - typhoeus xmldsig GIT @@ -21,12 +20,12 @@ GIT GIT remote: https://github.com/18F/identity-lexisnexis-api-client-gem.git - revision: 29f554ed2ea237c59a20fdbe4a675508b9c8539d - tag: v1.2.0 + revision: 42074c77e3570197791075afb7b0a0f60de84e89 + tag: v2.1.0 specs: - lexisnexis (1.2.0) + lexisnexis (2.1.0) dotenv - typhoeus + faraday GIT remote: https://github.com/18F/identity-proofer-gem.git @@ -57,15 +56,14 @@ GIT GIT remote: https://github.com/18f/identity-telephony.git - revision: d752e9e8ff08ee1412c1c58227f91b3a25474138 - tag: v0.1.4 + revision: 656d5cd779217b8be02bdb82ef747cd0fe0680e1 + tag: v0.1.5 specs: - identity-telephony (0.1.4) + identity-telephony (0.1.5) aws-sdk-pinpoint aws-sdk-pinpointsmsvoice i18n twilio-ruby - typhoeus GEM remote: https://rubygems.org/ @@ -239,7 +237,7 @@ GEM warden (~> 1.2.3) diff-lcs (1.3) docile (1.1.5) - dotenv (2.7.5) + dotenv (2.7.6) dotiw (4.0.1) actionpack (>= 4) i18n @@ -253,8 +251,6 @@ GEM equalizer (0.0.11) errbase (0.2.0) erubi (1.9.0) - ethon (0.12.0) - ffi (>= 1.3.0) exception_notification (4.4.0) actionmailer (>= 4.0, < 7) activesupport (>= 4.0, < 7) @@ -632,8 +628,6 @@ GEM rails (>= 3.1.1) randexp rotp (>= 3.2.0) - typhoeus (1.3.1) - ethon (>= 0.9.0) tzinfo (1.2.7) thread_safe (~> 0.1) uglifier (3.2.0) @@ -794,7 +788,6 @@ DEPENDENCIES timecop twilio-ruby two_factor_authentication (>= 2.1.1) - typhoeus uglifier (~> 3.2) user_agent_parser valid_email (>= 0.1.3) diff --git a/app/assets/images/id-card.svg b/app/assets/images/id-card.svg new file mode 100644 index 00000000000..da122afee93 --- /dev/null +++ b/app/assets/images/id-card.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + diff --git a/app/assets/javascripts/assets.js.erb b/app/assets/javascripts/assets.js.erb index 7938bc44986..17ba66110d1 100644 --- a/app/assets/javascripts/assets.js.erb +++ b/app/assets/javascripts/assets.js.erb @@ -2,12 +2,8 @@ window.LoginGov = window.LoginGov || {}; window.LoginGov.assets = {}; <% keys = [ - 'clock.svg', - 'state-id-sample-front.jpg', - 'plus.svg', - 'minus.svg', - 'up-carat-thin.svg', - 'close-white-alt.svg' + 'close-white-alt.svg', + 'id-card.svg', ] %> <% keys.each do |key| %> diff --git a/app/assets/javascripts/i18n-strings.js.erb b/app/assets/javascripts/i18n-strings.js.erb index 06a73619184..8136cb8cdcf 100644 --- a/app/assets/javascripts/i18n-strings.js.erb +++ b/app/assets/javascripts/i18n-strings.js.erb @@ -51,22 +51,16 @@ window.LoginGov = window.LoginGov || {}; 'doc_auth.buttons.take_picture', 'doc_auth.forms.selected_file', 'doc_auth.forms.change_file', - 'doc_auth.forms.choose_file', + 'doc_auth.forms.choose_file_html', + 'doc_auth.headings.document_capture', 'doc_auth.headings.upload_front', 'doc_auth.headings.upload_back', - 'image_description.accordian_plus_buttom', - 'image_description.accordian_minus_buttom', - 'users.personal_key.close', - 'doc_auth.tips.title', - 'doc_auth.tips.title_more', - 'doc_auth.tips.header_text', - 'doc_auth.tips.text1', - 'doc_auth.tips.text2', - 'doc_auth.tips.text3', - 'doc_auth.tips.text4', - 'doc_auth.tips.text5', - 'doc_auth.tips.text6', - 'doc_auth.tips.text7' + 'doc_auth.tips.document_capture_header_text', + 'doc_auth.tips.document_capture_id_text1', + 'doc_auth.tips.document_capture_id_text2', + 'doc_auth.tips.document_capture_id_text3', + 'doc_auth.tips.document_capture_id_text4', + 'users.personal_key.close' ] %> window.LoginGov.I18n = { diff --git a/app/assets/stylesheets/components/_accordion.scss b/app/assets/stylesheets/components/_accordion.scss deleted file mode 100644 index 3a59a7e044c..00000000000 --- a/app/assets/stylesheets/components/_accordion.scss +++ /dev/null @@ -1,87 +0,0 @@ -.no-js { - .accordion { - .accordion-header { - cursor: initial; - } - - .accordion-footer { - display: none; - } - - .accordion-content { - display: block; - } - - [class*="btn-"] { - display: none; - } - } -} - -.accordion { - border: $border-width solid $border-color; - border-radius: $border-radius-md; - - .accordion-header { - color: $blue; - cursor: pointer; - position: relative; - - img { - position: absolute; - right: 1rem; - top: .8rem; - } - } - - .accordion-content { - border-top: $border-width solid $border-color; - display: none; - opacity: 1; - - &.shown { - display: block; - } - } - - .accordion-footer { - background-color: $blue-lightest; - border-top: $border-width solid $border-color; - color: $blue; - cursor: pointer; - text-align: center; - - img { - margin-top: -2px; - vertical-align: middle; - } - } -} - -.animate-in { - animation: accordionIn .2s normal ease-in both 1; -} - -.animate-out { - animation: accordionOut .15s normal ease-out both 1; -} - -@keyframes accordionIn { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -@keyframes accordionOut { - 0% { - opacity: 1; - } - - 100% { - opacity: 0; - } -} diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss index 53ca150d641..b7c60ad8f45 100644 --- a/app/assets/stylesheets/components/all.scss +++ b/app/assets/stylesheets/components/all.scss @@ -21,7 +21,6 @@ @import 'personal-key'; @import 'position'; @import 'tooltip'; -@import 'accordion'; @import 'util'; @import 'verification-badge'; @import 'spinner'; diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index e00e7e995e3..d6f4032bbe1 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -3,8 +3,7 @@ footer, .btn, .btn-border, - .ico, - .accordion { + .ico { display: none; } } diff --git a/app/forms/security_event_form.rb b/app/forms/security_event_form.rb index fd1301abfd4..c9c68d3d4fe 100644 --- a/app/forms/security_event_form.rb +++ b/app/forms/security_event_form.rb @@ -213,7 +213,22 @@ def identity return if event.blank? || !service_provider return @identity if defined?(@identity) - @identity = Identity.find_by( + @identity = if service_provider.agency_id + identity_from_agency_identity + else + identity_from_identity + end + end + + def identity_from_agency_identity + AgencyIdentity.find_by( + uuid: event.dig('subject', 'sub'), + agency_id: service_provider.agency_id, + ) + end + + def identity_from_identity + Identity.find_by( uuid: event.dig('subject', 'sub'), service_provider: service_provider.issuer, ) diff --git a/app/helpers/accordion_helper.rb b/app/helpers/accordion_helper.rb index 99520f6dc2d..d744472420f 100644 --- a/app/helpers/accordion_helper.rb +++ b/app/helpers/accordion_helper.rb @@ -1,5 +1,5 @@ module AccordionHelper - # Options hash values: wrapper_css, hide_header, hide_close_link + # Options hash values: heading_level, hide_close_link def accordion(target_id, header_text, options = {}, &block) locals = { target_id: target_id, diff --git a/app/javascript/app/components/accordion.js b/app/javascript/app/components/accordion.js deleted file mode 100644 index 186ddd19985..00000000000 --- a/app/javascript/app/components/accordion.js +++ /dev/null @@ -1,98 +0,0 @@ -import 'classlist.js'; -import Events from '../utils/events'; - -class Accordion extends Events { - constructor(el) { - super(); - - this.el = el; - this.controls = [].slice.call(el.querySelectorAll('[aria-controls]')); - this.content = el.querySelector('.accordion-content'); - this.header = el.querySelector('.accordion-header-controls'); - this.collapsedIcon = el.querySelector('.plus-icon'); - this.shownIcon = el.querySelector('.minus-icon'); - - this.handleClick = this.handleClick.bind(this); - this.handleKeyUp = this.handleKeyUp.bind(this); - } - - setup() { - if (!this.isInitialized()) { - this.bindEvents(); - this.onInitialize(); - } - } - - bindEvents() { - this.controls.forEach((control) => { - control.addEventListener('click', this.handleClick); - control.addEventListener('keyup', this.handleKeyUp); - }); - - if (!('animation' in this.content.style)) return; - - this.content.addEventListener('animationend', (event) => { - const { animationName } = event; - - if (animationName === 'accordionOut') { - this.content.classList.remove('shown'); - } - }); - } - - onInitialize() { - this.setExpanded(false); - this.collapsedIcon.classList.remove('display-none'); - this.el.setAttribute('data-initialized', ''); - } - - isInitialized() { - return this.el.hasAttribute('data-initialized'); - } - - handleClick() { - const expandedState = this.header.getAttribute('aria-expanded'); - - if (expandedState === 'false') { - this.open(); - } else if (expandedState === 'true') { - this.close(); - } - } - - handleKeyUp(event) { - const keyCode = event.keyCode || event.which; - - if (keyCode === 13 || keyCode === 32) { - this.handleClick(); - } - } - - setExpanded(bool) { - this.header.setAttribute('aria-expanded', bool); - } - - open() { - this.setExpanded(true); - this.collapsedIcon.classList.add('display-none'); - this.shownIcon.classList.remove('display-none'); - this.content.classList.add('shown'); - this.content.classList.remove('animate-out'); - this.content.classList.add('animate-in'); - this.content.setAttribute('aria-hidden', 'false'); - this.emit('accordion.show'); - } - - close() { - this.setExpanded(false); - this.collapsedIcon.classList.remove('display-none'); - this.shownIcon.classList.add('display-none'); - this.content.classList.remove('animate-in'); - this.content.classList.add('animate-out'); - this.content.setAttribute('aria-hidden', 'true'); - this.emit('accordion.hide'); - this.header.focus(); - } -} - -export default Accordion; diff --git a/app/javascript/app/components/index.js b/app/javascript/app/components/index.js index 20a2b357f31..69c77cfcefb 100644 --- a/app/javascript/app/components/index.js +++ b/app/javascript/app/components/index.js @@ -1,6 +1,5 @@ import focusTrapProxy from './focus-trap-proxy'; import modal from './modal'; -import Accordion from './accordion'; window.LoginGov = window.LoginGov || {}; const { LoginGov } = window; @@ -8,15 +7,4 @@ const trapModal = modal(focusTrapProxy); LoginGov.Modal = trapModal; -document.addEventListener('DOMContentLoaded', () => { - const elements = document.querySelectorAll('.accordion'); - - LoginGov.accordions = [].slice.call(elements).map((element) => { - const accordion = new Accordion(element); - accordion.setup(); - - return accordion; - }); -}); - export { trapModal as Modal }; diff --git a/app/javascript/app/document-capture/components/accordion.jsx b/app/javascript/app/document-capture/components/accordion.jsx deleted file mode 100644 index daa852459b3..00000000000 --- a/app/javascript/app/document-capture/components/accordion.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useEffect, useRef, useMemo } from 'react'; -import PropTypes from 'prop-types'; -import BaseAccordion from '../../components/accordion'; -import useI18n from '../hooks/use-i18n'; -import Image from './image'; - -function Accordion({ title, children }) { - const elementRef = useRef(null); - const instanceId = useMemo(() => { - Accordion.instances += 1; - return Accordion.instances; - }, []); - const t = useI18n(); - useEffect(() => { - new BaseAccordion(elementRef.current).setup(); - }, []); - - const contentId = `accordion-content-${instanceId}`; - - return ( -
-
- -
- -
- ); -} - -Accordion.instances = 0; - -Accordion.propTypes = { - title: PropTypes.node.isRequired, - children: PropTypes.node.isRequired, -}; - -export default Accordion; diff --git a/app/javascript/app/document-capture/components/acuant-capture.jsx b/app/javascript/app/document-capture/components/acuant-capture.jsx index e5745c763b3..e7f04d35458 100644 --- a/app/javascript/app/document-capture/components/acuant-capture.jsx +++ b/app/javascript/app/document-capture/components/acuant-capture.jsx @@ -8,7 +8,7 @@ function AcuantCapture() { const { isReady, isError } = useContext(AcuantContext); const [isCapturing, setIsCapturing] = useState(false); const [capture, setCapture] = useState(null); - const t = useI18n(); + const { t } = useI18n(); if (isError) { return 'Error!'; diff --git a/app/javascript/app/document-capture/components/document-capture.jsx b/app/javascript/app/document-capture/components/document-capture.jsx index a851a62aff4..9b0616d569b 100644 --- a/app/javascript/app/document-capture/components/document-capture.jsx +++ b/app/javascript/app/document-capture/components/document-capture.jsx @@ -1,7 +1,5 @@ import React, { useState } from 'react'; import AcuantCapture from './acuant-capture'; -import DocumentTips from './document-tips'; -import Image from './image'; import FormSteps from './form-steps'; import DocumentsStep, { isValid as isDocumentsStepValid } from './documents-step'; import Submission from './submission'; @@ -9,34 +7,24 @@ import Submission from './submission'; function DocumentCapture() { const [formValues, setFormValues] = useState(null); - const sample = ( - Sample front of state issued ID - ); - return formValues ? ( ) : ( - <> - - - 'Selfie' }, - { name: 'confirm', component: () => 'Confirm?' }, - ]} - onComplete={setFormValues} - /> - + 'Confirm?' }, + ]} + onComplete={setFormValues} + /> ); } diff --git a/app/javascript/app/document-capture/components/document-tips.jsx b/app/javascript/app/document-capture/components/document-tips.jsx deleted file mode 100644 index 07f0fe5c684..00000000000 --- a/app/javascript/app/document-capture/components/document-tips.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Accordion from './accordion'; -import useI18n from '../hooks/use-i18n'; - -function DocumentTips({ sample }) { - const t = useI18n(); - - const title = ( - <> - {t('doc_auth.tips.title')} - {` ${t('doc_auth.tips.title_more')}`} - - ); - - return ( - - {t('doc_auth.tips.header_text')} -
    -
  • {t('doc_auth.tips.text1')}
  • -
  • {t('doc_auth.tips.text2')}
  • -
  • {t('doc_auth.tips.text3')}
  • -
  • {t('doc_auth.tips.text4')}
  • -
  • {t('doc_auth.tips.text5')}
  • -
  • {t('doc_auth.tips.text6')}
  • -
  • {t('doc_auth.tips.text7')}
  • -
- {!!sample &&
{sample}
} -
- ); -} - -DocumentTips.propTypes = { - sample: PropTypes.node, -}; - -DocumentTips.defaultProps = { - sample: null, -}; - -export default DocumentTips; diff --git a/app/javascript/app/document-capture/components/documents-step.jsx b/app/javascript/app/document-capture/components/documents-step.jsx index 8383347977e..3d82f607b69 100644 --- a/app/javascript/app/document-capture/components/documents-step.jsx +++ b/app/javascript/app/document-capture/components/documents-step.jsx @@ -1,7 +1,9 @@ -import React from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import FileInput from './file-input'; +import PageHeading from './page-heading'; import useI18n from '../hooks/use-i18n'; +import DeviceContext from '../context/device'; /** * Sides of document to present as file input. @@ -11,10 +13,21 @@ import useI18n from '../hooks/use-i18n'; const DOCUMENT_SIDES = ['front', 'back']; function DocumentsStep({ value, onChange }) { - const t = useI18n(); + const { t } = useI18n(); + const { isMobile } = useContext(DeviceContext); return ( <> + {t('doc_auth.headings.document_capture')} +

+ {t('doc_auth.tips.document_capture_header_text')} +

+
    +
  • {t('doc_auth.tips.document_capture_id_text1')}
  • +
  • {t('doc_auth.tips.document_capture_id_text2')}
  • +
  • {t('doc_auth.tips.document_capture_id_text3')}
  • + {!isMobile &&
  • {t('doc_auth.tips.document_capture_id_text4')}
  • } +
{DOCUMENT_SIDES.map((side) => { const label = t(`doc_auth.headings.upload_${side}`); const inputKey = `${side}_image`; diff --git a/app/javascript/app/document-capture/components/file-input.jsx b/app/javascript/app/document-capture/components/file-input.jsx index 1e9a9d54cc3..342d474a48a 100644 --- a/app/javascript/app/document-capture/components/file-input.jsx +++ b/app/javascript/app/document-capture/components/file-input.jsx @@ -16,7 +16,7 @@ export function isImageFile(file) { } function FileInput({ label, hint, accept, value, onChange }) { - const t = useI18n(); + const { t, formatHTML } = useI18n(); const instanceId = useInstanceId(); const inputId = `file-input-${instanceId}`; const hintId = `${inputId}-hint`; @@ -57,7 +57,14 @@ function FileInput({ label, hint, accept, value, onChange }) { )} {!value && ( )}
diff --git a/app/javascript/app/document-capture/components/form-steps.jsx b/app/javascript/app/document-capture/components/form-steps.jsx index f4a3071cee2..729decfc7c8 100644 --- a/app/javascript/app/document-capture/components/form-steps.jsx +++ b/app/javascript/app/document-capture/components/form-steps.jsx @@ -56,7 +56,7 @@ export function getLastValidStepIndex(steps, values) { function FormSteps({ steps, onComplete }) { const [values, setValues] = useState({}); const [stepName, setStepName] = useHistoryParam('step'); - const t = useI18n(); + const { t } = useI18n(); // An "effective" step is computed in consideration of the facts that (1) there may be no history // parameter present, in which case the first step should be used, and (2) the values may not be diff --git a/app/javascript/app/document-capture/components/full-screen.jsx b/app/javascript/app/document-capture/components/full-screen.jsx index 6fccc99dd72..99af2c6fea2 100644 --- a/app/javascript/app/document-capture/components/full-screen.jsx +++ b/app/javascript/app/document-capture/components/full-screen.jsx @@ -5,7 +5,7 @@ import Image from './image'; import useI18n from '../hooks/use-i18n'; function FullScreen({ onRequestClose, children }) { - const t = useI18n(); + const { t } = useI18n(); const modalRef = useRef(/** @type {?HTMLDivElement} */ (null)); const trapRef = useRef(/** @type {?import('focus-trap').FocusTrap} */ (null)); const onRequestCloseRef = useRef(onRequestClose); diff --git a/app/javascript/app/document-capture/components/page-heading.jsx b/app/javascript/app/document-capture/components/page-heading.jsx new file mode 100644 index 00000000000..03cc776c73d --- /dev/null +++ b/app/javascript/app/document-capture/components/page-heading.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +function PageHeading({ children }) { + return

{children}

; +} + +PageHeading.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default PageHeading; diff --git a/app/javascript/app/document-capture/components/submission-pending.jsx b/app/javascript/app/document-capture/components/submission-pending.jsx index 14070cb1a2d..7f67b8b6ee0 100644 --- a/app/javascript/app/document-capture/components/submission-pending.jsx +++ b/app/javascript/app/document-capture/components/submission-pending.jsx @@ -4,7 +4,7 @@ import Image from './image'; function SubmissionPending() { return (
- +

We are processing your images…

This might take up to a minute. We’ll load the next step automatically when it’s done.

Thanks for your patience!

diff --git a/app/javascript/app/document-capture/context/acuant.jsx b/app/javascript/app/document-capture/context/acuant.jsx index 0915d39fb28..b7c4e266d31 100644 --- a/app/javascript/app/document-capture/context/acuant.jsx +++ b/app/javascript/app/document-capture/context/acuant.jsx @@ -32,6 +32,7 @@ function AcuantContextProvider({ sdkSrc, credentials, endpoint, children }) { const script = document.createElement('script'); script.async = true; script.src = sdkSrc; + script.onerror = () => setIsError(true); document.body.appendChild(script); return () => { diff --git a/app/javascript/app/document-capture/context/device.js b/app/javascript/app/document-capture/context/device.js new file mode 100644 index 00000000000..71b94e183c0 --- /dev/null +++ b/app/javascript/app/document-capture/context/device.js @@ -0,0 +1,11 @@ +import { createContext } from 'react'; + +/** + * @typedef DeviceContext + * + * @prop {boolean} isMobile Device is a mobile device. + */ + +const DeviceContext = createContext(/** @type {DeviceContext} */ ({ isMobile: false })); + +export default DeviceContext; diff --git a/app/javascript/app/document-capture/hooks/use-i18n.js b/app/javascript/app/document-capture/hooks/use-i18n.js index 5a21c8e6aac..fed87077b6b 100644 --- a/app/javascript/app/document-capture/hooks/use-i18n.js +++ b/app/javascript/app/document-capture/hooks/use-i18n.js @@ -1,10 +1,67 @@ -import { useContext } from 'react'; +import { createElement, cloneElement, useContext } from 'react'; import I18nContext from '../context/i18n'; +/** @typedef {import('react').FC|import('react').ComponentClass} Component */ + +/** + * Given an HTML string and an object of tag names to React component, returns a new React node + * where the mapped tag names are replaced by the resulting element of the rendered component. + * + * Note that this is a very simplistic interpolation of HTML. It only supports well-balanced, non- + * nested tag names, where there are no attributes or excess whitespace within the tag names. The + * tag name cannot contain regular expression special characters. + * + * While the subject markup itself cannot contain attributes, the return value of the component can + * be any valid React element, with or without additional attributes. + * + * @example + * ``` + * formatHTML('Hello world!', { + * 'lg-sparkles': ({children}) => {children} + * }); + * ``` + * + * @param {string} html HTML to format. + * @param {Record} handlers Mapping of tag names to components. + * + * @return {import('react').ReactNode} + */ +export function formatHTML(html, handlers) { + const pattern = new RegExp(``, 'g'); + const matches = html.match(pattern); + if (!matches) { + return html; + } + + /** @type {Array} */ + const parts = html.split(pattern); + + for (let i = 0; i < matches.length; i += 2) { + const tag = matches[i].slice(1, -1); + const part = /** @type {string} */ (parts[i + 1]); + const replacement = createElement(handlers[tag], null, part); + parts[i + 1] = cloneElement(replacement, { key: part }); + } + + return parts.filter(Boolean); +} + function useI18n() { const strings = useContext(I18nContext); + + /** + * Returns the translated string by the given key. + * + * @param {string} key Key to retrieve. + * + * @return {string} Translated string. + */ const t = (key) => (Object.prototype.hasOwnProperty.call(strings, key) ? strings[key] : key); - return t; + + return { + t, + formatHTML, + }; } export default useI18n; diff --git a/app/javascript/packs/document-capture.jsx b/app/javascript/packs/document-capture.jsx index ef61ee13f84..355ac48ae54 100644 --- a/app/javascript/packs/document-capture.jsx +++ b/app/javascript/packs/document-capture.jsx @@ -3,6 +3,7 @@ import { render } from 'react-dom'; import DocumentCapture from '../app/document-capture/components/document-capture'; import AssetContext from '../app/document-capture/context/asset'; import I18nContext from '../app/document-capture/context/i18n'; +import DeviceContext from '../app/document-capture/context/device'; import { Provider as AcuantProvider } from '../app/document-capture/context/acuant'; const { I18n: i18n, assets } = window.LoginGov; @@ -11,6 +12,13 @@ function getMetaContent(name) { return document.querySelector(`meta[name="${name}"]`)?.content ?? null; } +/** @type {import('../app/document-capture/context/device').DeviceContext} */ +const device = { + isMobile: + 'mediaDevices' in window.navigator && + /ip(hone|ad|od)|android/i.test(window.navigator.userAgent), +}; + const appRoot = document.getElementById('document-capture-form'); appRoot.innerHTML = ''; render( @@ -20,7 +28,9 @@ render( > - + + + , diff --git a/app/javascript/packs/image-preview.js b/app/javascript/packs/image-preview.js index 2a901dc30eb..523319135e2 100644 --- a/app/javascript/packs/image-preview.js +++ b/app/javascript/packs/image-preview.js @@ -28,9 +28,17 @@ function imagePreview() { document.addEventListener('DOMContentLoaded', imagePreview); -function imagePreviewFunction(imageId, imageTarget) { +function imagePreviewFunction(imageType) { + const imageId = `doc_auth_${imageType}_image`; + const imageIdSelector = `#${imageId}`; + const takeImageSelector = `#take_${imageType}_picture`; + const targetIdSelector = `#${imageType}_target`; + return function () { - $(imageId).on('change', function (event) { + $(takeImageSelector).on('click', function () { + document.getElementById(imageId).click(); + }); + $(imageIdSelector).on('change', function (event) { $('.simple_form .alert-error').hide(); $('.simple_form .alert-notice').hide(); const { files } = event.target; @@ -43,19 +51,19 @@ function imagePreviewFunction(imageId, imageTarget) { const ratio = this.height / this.width; img.width = displayWidth; img.height = displayWidth * ratio; - $(imageTarget).html(img); + $(targetIdSelector).html(img); }; img.src = file.target.result; - $(imageTarget).html(img); + $(targetIdSelector).html(img); }; reader.readAsDataURL(image); }); }; } -const frontImagePreview = imagePreviewFunction('#doc_auth_front_image', '#front_target'); -const backImagePreview = imagePreviewFunction('#doc_auth_back_image', '#back_target'); -const selfieImagePreview = imagePreviewFunction('#doc_auth_selfie_image', '#selfie_target'); +const frontImagePreview = imagePreviewFunction('front'); +const backImagePreview = imagePreviewFunction('back'); +const selfieImagePreview = imagePreviewFunction('selfie'); document.addEventListener('DOMContentLoaded', frontImagePreview); document.addEventListener('DOMContentLoaded', backImagePreview); diff --git a/app/presenters/failure_presenter.rb b/app/presenters/failure_presenter.rb index f228d58c5a2..bf2474e1526 100644 --- a/app/presenters/failure_presenter.rb +++ b/app/presenters/failure_presenter.rb @@ -4,17 +4,14 @@ class FailurePresenter STATE_CONFIG = { failure: { icon: 'alert/fail-x.svg', - alt_text: 'failure', color: 'red', }, locked: { icon: 'alert/temp-lock.svg', - alt_text: 'locked', color: 'red', }, warning: { icon: 'alert/warning-lg.svg', - alt_text: 'warning', color: 'yellow', }, }.freeze diff --git a/app/services/acuant/acuant_client.rb b/app/services/acuant/acuant_client.rb index ba93cf64a6e..429e285d0d1 100644 --- a/app/services/acuant/acuant_client.rb +++ b/app/services/acuant/acuant_client.rb @@ -33,7 +33,7 @@ def post_selfie(image:, instance_id:) merge_facial_match_and_liveness_response(facial_match_response, liveness_response) end - # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def post_images(front_image:, back_image:, selfie_image:, liveness_checking_enabled: nil, instance_id: nil) document = create_document @@ -49,12 +49,16 @@ def post_images(front_image:, back_image:, selfie_image:, if results.success? && liveness_checking_enabled pii = results.pii_from_doc selfie_response = post_selfie(image: selfie_image, instance_id: instance_id) - Acuant::Responses::ResponseWithPii.new(selfie_response, pii) + Acuant::Responses::ResponseWithPii.new( + acuant_response: selfie_response, + pii: pii, + billed: results.result_code&.billed?, + ) else results end end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength def get_results(instance_id:) Requests::GetResultsRequest.new(instance_id: instance_id).fetch diff --git a/app/services/acuant/responses/get_results_response.rb b/app/services/acuant/responses/get_results_response.rb index 0a12b28c138..59a7ccb47f3 100644 --- a/app/services/acuant/responses/get_results_response.rb +++ b/app/services/acuant/responses/get_results_response.rb @@ -1,9 +1,6 @@ module Acuant module Responses class GetResultsResponse < Acuant::Response - GOOD_RESULT = 1 - FYI_RESULT = 2 - def initialize(http_response) @http_response = http_response super( @@ -25,11 +22,17 @@ def pii_from_doc def to_h { success: success?, - erorrs: errors, + errors: errors, exception: exception, + result: result_code.name, } end + # @return [Acuant::ResultCode::ResultCode] + def result_code + Acuant::ResultCodes.from_int(parsed_response_body['Result']) + end + private attr_reader :http_response @@ -57,7 +60,7 @@ def raw_alerts end def successful_result? - parsed_response_body['Result'] == GOOD_RESULT + result_code == Acuant::ResultCodes::PASSED end end end diff --git a/app/services/acuant/responses/liveness_response.rb b/app/services/acuant/responses/liveness_response.rb index 98a89ce9e21..2ae6aba11ef 100644 --- a/app/services/acuant/responses/liveness_response.rb +++ b/app/services/acuant/responses/liveness_response.rb @@ -30,6 +30,7 @@ def extra_attributes { liveness_score: liveness_score, acuant_error: acuant_error, + liveness_assessment: liveness_assessment, } end @@ -42,7 +43,11 @@ def parsed_response_body end def successful_result? - parsed_response_body.dig('LivenessResult', 'LivenessAssessment') == 'Live' + liveness_assessment == 'Live' + end + + def liveness_assessment + parsed_response_body.dig('LivenessResult', 'LivenessAssessment') end end end diff --git a/app/services/acuant/responses/response_with_pii.rb b/app/services/acuant/responses/response_with_pii.rb index bcea99335f4..fc2a6fc6905 100644 --- a/app/services/acuant/responses/response_with_pii.rb +++ b/app/services/acuant/responses/response_with_pii.rb @@ -1,12 +1,12 @@ module Acuant module Responses class ResponseWithPii < Acuant::Response - def initialize(acuant_response, pii) + def initialize(acuant_response:, pii:, billed:) super( success: acuant_response.success?, errors: acuant_response.errors, exception: acuant_response.exception, - extra: acuant_response.extra, + extra: acuant_response.extra.merge(billed: billed), ) @pii = pii end diff --git a/app/services/acuant/result_codes.rb b/app/services/acuant/result_codes.rb new file mode 100644 index 00000000000..5377d0c3386 --- /dev/null +++ b/app/services/acuant/result_codes.rb @@ -0,0 +1,36 @@ +module Acuant + module ResultCodes + ResultCode = Struct.new(:code, :name, :billed) do + alias_method :billed?, :billed + end + + # The authentication test results are unknown. We are not billed for these + UNKNOWN = ResultCode.new(0, 'Unknown', false).freeze + # The authentication test passed. + PASSED = ResultCode.new(1, 'Passed', true).freeze + # The authentication test failed. + FAILED = ResultCode.new(2, 'Failed', true).freeze + # The authentication test was skipped (was not run). + SKIPPED = ResultCode.new(3, 'Skipped', true).freeze + # The authentication test was inconclusive and further investigation is warranted. + CAUTION = ResultCode.new(4, 'Caution', true).freeze + # The authentication test results requires user attention. + ATTENTION = ResultCode.new(5, 'Attention', true).freeze + + ALL = [ + UNKNOWN, + PASSED, + FAILED, + SKIPPED, + CAUTION, + ATTENTION, + ].freeze + + BY_CODE = ALL.map { |r| [r.code, r] }.to_h.freeze + + # @return [ResultCode] + def self.from_int(code) + BY_CODE[code] + end + end +end diff --git a/app/services/db/proofing_cost/add_user_proofing_cost.rb b/app/services/db/proofing_cost/add_user_proofing_cost.rb index 22d0b85fc33..4430e283822 100644 --- a/app/services/db/proofing_cost/add_user_proofing_cost.rb +++ b/app/services/db/proofing_cost/add_user_proofing_cost.rb @@ -4,6 +4,7 @@ class AddUserProofingCost TOKEN_WHITELIST = %i[ acuant_front_image acuant_back_image + acuant_result aamva lexis_nexis_resolution lexis_nexis_address diff --git a/app/services/doc_auth_mock/doc_auth_mock_client.rb b/app/services/doc_auth_mock/doc_auth_mock_client.rb index ce1f0a84222..4a980c735d5 100644 --- a/app/services/doc_auth_mock/doc_auth_mock_client.rb +++ b/app/services/doc_auth_mock/doc_auth_mock_client.rb @@ -1,4 +1,4 @@ -# rubocop:disable Lint/UnusedMethodArgument +# rubocop:disable Lint/UnusedMethodArgument, Metrics/ClassLength module DocAuthMock class DocAuthMockClient class << self @@ -48,7 +48,7 @@ def post_selfie(image:, instance_id:) Acuant::Response.new(success: true) end - # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def post_images(front_image:, back_image:, selfie_image:, liveness_checking_enabled: nil, instance_id: nil) return mocked_response_for_method(__method__) if method_mocked?(__method__) @@ -64,12 +64,16 @@ def post_images(front_image:, back_image:, selfie_image:, if results.success? && liveness_checking_enabled pii = results.pii_from_doc selfie_response = post_selfie(image: selfie_image, instance_id: instance_id) - Acuant::Responses::ResponseWithPii.new(selfie_response, pii) + Acuant::Responses::ResponseWithPii.new( + acuant_response: selfie_response, + pii: pii, + billed: true, + ) else results end end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength def get_results(instance_id:) return mocked_response_for_method(__method__) if method_mocked?(__method__) @@ -129,4 +133,4 @@ def failure(message, extra = nil) end end end -# rubocop:enable Lint/UnusedMethodArgument +# rubocop:enable Lint/UnusedMethodArgument, Metrics/ClassLength diff --git a/app/services/doc_auth_mock/responses/get_results_response.rb b/app/services/doc_auth_mock/responses/get_results_response.rb index 2136dc05415..d0cfc7b48b3 100644 --- a/app/services/doc_auth_mock/responses/get_results_response.rb +++ b/app/services/doc_auth_mock/responses/get_results_response.rb @@ -3,12 +3,19 @@ module Responses class GetResultsResponse < Acuant::Response attr_reader :pii_from_doc - def initialize(success: true, errors: [], exception: nil, pii_from_doc:) + def initialize( + success: true, + errors: [], + exception: nil, + pii_from_doc:, + billed: true + ) @pii_from_doc = pii_from_doc super( success: success, errors: errors, exception: exception, + extra: { billed: billed }, ) end end diff --git a/app/services/doc_auth_mock/result_response_builder.rb b/app/services/doc_auth_mock/result_response_builder.rb index 0abe81e0356..1085da792b6 100644 --- a/app/services/doc_auth_mock/result_response_builder.rb +++ b/app/services/doc_auth_mock/result_response_builder.rb @@ -27,6 +27,7 @@ def call success: success?, errors: errors, pii_from_doc: pii_from_doc, + billed: true, ) end diff --git a/app/services/google_analytics_measurement.rb b/app/services/google_analytics_measurement.rb index 2026c3d799b..bbe76e54389 100644 --- a/app/services/google_analytics_measurement.rb +++ b/app/services/google_analytics_measurement.rb @@ -6,7 +6,7 @@ class GoogleAnalyticsMeasurement cattr_accessor :adapter do Faraday.new(url: GA_URL, request: { open_timeout: TIMEOUT, timeout: TIMEOUT }) do |faraday| - faraday.adapter :typhoeus + faraday.adapter :net_http end end @@ -19,6 +19,7 @@ def initialize(category:, event_action:, method:, client_id:) def send_event adapter.post do |request| + request.headers['Content-Type'] = 'application/json' request.body = request_body end rescue Faraday::TimeoutError, Faraday::ConnectionFailed => err @@ -29,13 +30,13 @@ def send_event def request_body { - v: 1, + v: '1', tid: Figaro.env.google_analytics_key, t: :event, ec: category, ea: event_action, el: method, cid: client_id, - } + }.to_json end end diff --git a/app/services/idv/flows/doc_auth_flow.rb b/app/services/idv/flows/doc_auth_flow.rb index f7d8ac43a84..b74f7591ca8 100644 --- a/app/services/idv/flows/doc_auth_flow.rb +++ b/app/services/idv/flows/doc_auth_flow.rb @@ -8,6 +8,7 @@ class DocAuthFlow < Flow::BaseFlow link_sent: Idv::Steps::LinkSentStep, email_sent: Idv::Steps::EmailSentStep, document_capture: Idv::Steps::DocumentCaptureStep, + mobile_document_capture: Idv::Steps::MobileDocumentCaptureStep, front_image: Idv::Steps::FrontImageStep, back_image: Idv::Steps::BackImageStep, mobile_front_image: Idv::Steps::MobileFrontImageStep, diff --git a/app/services/idv/steps/back_image_step.rb b/app/services/idv/steps/back_image_step.rb index 33f66a3d47a..bcaa19bb69b 100644 --- a/app/services/idv/steps/back_image_step.rb +++ b/app/services/idv/steps/back_image_step.rb @@ -19,6 +19,7 @@ def fetch_doc_auth_results_or_redirect_to_selfie return if liveness_checking_enabled? get_results_response = doc_auth_client.get_results(instance_id: flow_session[:instance_id]) + add_cost(:acuant_result) if get_results_response.to_h[:billed] if get_results_response.success? mark_step_complete(:selfie) save_proofing_components diff --git a/app/services/idv/steps/doc_auth_base_step.rb b/app/services/idv/steps/doc_auth_base_step.rb index 3c74fef5b7b..eae7f302f1a 100644 --- a/app/services/idv/steps/doc_auth_base_step.rb +++ b/app/services/idv/steps/doc_auth_base_step.rb @@ -2,9 +2,6 @@ module Idv module Steps class DocAuthBaseStep < Flow::BaseStep - GOOD_RESULT = 1 - FYI_RESULT = 2 - def initialize(flow) super(flow, :doc_auth) end @@ -135,7 +132,7 @@ def post_images liveness_checking_enabled: liveness_checking_enabled?, ) # DP: should these cost recordings happen in the doc_auth_client? - add_costs + add_costs(result) result end @@ -171,10 +168,11 @@ def add_cost(token) Db::ProofingCost::AddUserProofingCost.call(user_id, token) end - def add_costs + def add_costs(result) add_cost(:acuant_front_image) add_cost(:acuant_back_image) add_cost(:acuant_selfie) if liveness_checking_enabled? + add_cost(:acuant_result) if result.to_h[:billed] end def sp_session @@ -194,6 +192,7 @@ def mark_document_capture_or_image_upload_steps_complete mark_step_complete(:mobile_back_image) else mark_step_complete(:document_capture) + mark_step_complete(:mobile_document_capture) end end diff --git a/app/services/idv/steps/mobile_document_capture_step.rb b/app/services/idv/steps/mobile_document_capture_step.rb new file mode 100644 index 00000000000..678ec387e3f --- /dev/null +++ b/app/services/idv/steps/mobile_document_capture_step.rb @@ -0,0 +1,36 @@ +module Idv + module Steps + class MobileDocumentCaptureStep < DocAuthBaseStep + def call + response = post_images + if response.success? + save_proofing_components + extract_pii_from_doc(response) + else + handle_document_verification_failure(response) + end + end + + private + + def handle_document_verification_failure(response) + mark_step_incomplete(:mobile_document_capture) + notice = if liveness_checking_enabled? + { notice: I18n.t('errors.doc_auth.document_capture_info_with_selfie_html') } + else + { notice: I18n.t('errors.doc_auth.document_capture_info_html') } + end + extra = response.to_h.merge(notice) + failure(response.errors.first, extra) + end + + def form_submit + Idv::DocumentCaptureForm. + new(liveness_checking_enabled: liveness_checking_enabled?). + submit(permit(:front_image, :front_image_data_url, + :back_image, :back_image_data_url, + :selfie_image, :selfie_image_data_url)) + end + end + end +end diff --git a/app/services/idv/steps/upload_step.rb b/app/services/idv/steps/upload_step.rb index 1f325216670..36e25bd4bc6 100644 --- a/app/services/idv/steps/upload_step.rb +++ b/app/services/idv/steps/upload_step.rb @@ -36,6 +36,7 @@ def desktop mark_step_complete(:email_sent) mark_step_complete(:mobile_front_image) mark_step_complete(:mobile_back_image) + mark_step_complete(:mobile_document_capture) end def mobile @@ -44,6 +45,7 @@ def mobile mark_step_complete(:email_sent) mark_step_complete(:front_image) mark_step_complete(:back_image) + mark_step_complete(:document_capture) end end end diff --git a/app/services/push_notification/account_delete.rb b/app/services/push_notification/account_delete.rb index 4f23ef85d60..8a9d9bd9e66 100644 --- a/app/services/push_notification/account_delete.rb +++ b/app/services/push_notification/account_delete.rb @@ -99,7 +99,7 @@ def handle_failure(exception, agency_id, uuid) def faraday_adapter(url) Faraday.new(url: url) do |faraday| - faraday.adapter :typhoeus + faraday.adapter :net_http end end end diff --git a/app/views/accounts/_event_item.html.erb b/app/views/accounts/_event_item.html.erb new file mode 100644 index 00000000000..c7ae1bb64db --- /dev/null +++ b/app/views/accounts/_event_item.html.erb @@ -0,0 +1,13 @@ +
+
+
+
+ <%= event.event_type %> +
+ <%= event.last_sign_in_location_and_ip %> +
+
+ <%= local_time(event.happened_at, t('time.formats.event_timestamp')) %> +
+
+
diff --git a/app/views/accounts/_event_item.html.slim b/app/views/accounts/_event_item.html.slim deleted file mode 100644 index 5be8a0f69a5..00000000000 --- a/app/views/accounts/_event_item.html.slim +++ /dev/null @@ -1,7 +0,0 @@ -.p2.clearfix.border-top - .clearfix.mxn1 - .sm-col.sm-col-6.px1 - .bold.truncate = event.event_type - = event.last_sign_in_location_and_ip - .sm-col.sm-col-6.px1.sm-right-align - = local_time(event.happened_at, t('time.formats.event_timestamp')) diff --git a/app/views/accounts/_verified_account_badge.html.erb b/app/views/accounts/_verified_account_badge.html.erb new file mode 100644 index 00000000000..f0183c4a15f --- /dev/null +++ b/app/views/accounts/_verified_account_badge.html.erb @@ -0,0 +1,5 @@ + + <%= image_tag asset_url('alert/success.svg'), width: 16, + class: 'mr1 align-middle', id: 'verified_account_badge', alt: '' %> + <%= t('headings.account.verified_account') %> + diff --git a/app/views/accounts/_verified_account_badge.html.slim b/app/views/accounts/_verified_account_badge.html.slim deleted file mode 100644 index 51d9f4cdaff..00000000000 --- a/app/views/accounts/_verified_account_badge.html.slim +++ /dev/null @@ -1,4 +0,0 @@ -span - = image_tag asset_url('alert/success.svg'), width: 16, - class: 'mr1 align-middle', id: 'verified_account_badge' - = t('headings.account.verified_account') diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb new file mode 100644 index 00000000000..f0dee2d91f0 --- /dev/null +++ b/app/views/events/show.html.erb @@ -0,0 +1,20 @@ +<% content_for(:nav) do %> + <%= render 'accounts/nav_auth', greeting: @view_model.header_personalization %> +<% end %> + +<% title t('titles.account') %> + +

+ <%= t('headings.account.account_history') %> +

+
+
+ <%= @device.device_name %> + <%= image_tag asset_url('history.svg'), alt: '', width: 12, class: 'ml1' %> +
+ <% @events.each do |event| %> + <%= render event.event_partial, event: event %> + <% end %> +
+ +<%= link_to cancel_link_text, account_path, class: 'h5' %> diff --git a/app/views/events/show.html.slim b/app/views/events/show.html.slim deleted file mode 100644 index 6b724ef5dae..00000000000 --- a/app/views/events/show.html.slim +++ /dev/null @@ -1,15 +0,0 @@ -- content_for(:nav) do - = render 'accounts/nav_auth', greeting: @view_model.header_personalization - -- title t('titles.account') - -.mb3.profile-info-box - .bg-lightest-blue.pb1.pt1.px2.h6.caps.clearfix - = t('headings.account.account_history') - = image_tag asset_url('history.svg'), alt: '', width: 12, class: 'ml1' - br - = @device.device_name - - @events.each do |event| - = render event.event_partial, event: event - -= link_to cancel_link_text, account_path, class: 'h5' diff --git a/app/views/idv/doc_auth/_back_of_state_id_image.html.erb b/app/views/idv/doc_auth/_back_of_state_id_image.html.erb index c9e7a2c2d39..a4d39a3ff76 100644 --- a/app/views/idv/doc_auth/_back_of_state_id_image.html.erb +++ b/app/views/idv/doc_auth/_back_of_state_id_image.html.erb @@ -1,3 +1,3 @@ -<%= image_tag(asset_url('state-id-back.svg'), height: 140) %> +<%= image_tag(asset_url('state-id-back.svg'), alt: '', height: 140) %>

diff --git a/app/views/idv/doc_auth/_front_of_state_id_image.html.erb b/app/views/idv/doc_auth/_front_of_state_id_image.html.erb index d6a9b083894..24b9fa34377 100644 --- a/app/views/idv/doc_auth/_front_of_state_id_image.html.erb +++ b/app/views/idv/doc_auth/_front_of_state_id_image.html.erb @@ -1,3 +1,3 @@ -<%= image_tag(asset_url('state-id-front.svg'), height: 140) %> +<%= image_tag(asset_url('state-id-front.svg'), alt: '', height: 140) %>

diff --git a/app/views/idv/doc_auth/_start_over_or_cancel.html.erb b/app/views/idv/doc_auth/_start_over_or_cancel.html.erb index f190bcc4179..49986d4f6f2 100644 --- a/app/views/idv/doc_auth/_start_over_or_cancel.html.erb +++ b/app/views/idv/doc_auth/_start_over_or_cancel.html.erb @@ -1,9 +1,8 @@ -
<%= button_to( t('doc_auth.buttons.start_over'), idv_doc_auth_step_path(step: :reset), method: :put, class: 'btn btn-link', - form_class: 'inline-block' + form_class: 'inline-block mt3' ) %> <%= render 'shared/cancel', link: idv_cancel_path %> diff --git a/app/views/idv/doc_auth/back_image.html.erb b/app/views/idv/doc_auth/back_image.html.erb index 6d8a0291be9..95248c49359 100644 --- a/app/views/idv/doc_auth/back_image.html.erb +++ b/app/views/idv/doc_auth/back_image.html.erb @@ -5,11 +5,10 @@ <%= render 'idv/doc_auth/back_of_state_id_image' %>

- <%= t('doc_auth.headings.upload_back') %> +

-<%= accordion('totp-info', t('doc_auth.tips.title_html'), - wrapper_css: 'my2 col-12 fs-16p') do %> +<%= accordion('totp-info', t('doc_auth.tips.title_html')) do %> <%= render 'idv/doc_auth/tips_and_sample' %>
<%= image_tag(asset_url('state-id-sample-back.jpg'), height: 338, width: 450) %> diff --git a/app/views/idv/doc_auth/doc_success.html.erb b/app/views/idv/doc_auth/doc_success.html.erb index 9afab298a46..20a15f1c779 100644 --- a/app/views/idv/doc_auth/doc_success.html.erb +++ b/app/views/idv/doc_auth/doc_success.html.erb @@ -1,6 +1,6 @@ <% title t('doc_auth.titles.doc_auth') %> -<%= image_tag(asset_url('state-id-confirm@3x.png'), width: 210) %> +<%= image_tag(asset_url('state-id-confirm@3x.png'), alt: '', width: 210) %>

<%= t('doc_auth.forms.doc_success') %> diff --git a/app/views/idv/doc_auth/document_capture.html.erb b/app/views/idv/doc_auth/document_capture.html.erb index 76a1eca71e8..14fb819714e 100644 --- a/app/views/idv/doc_auth/document_capture.html.erb +++ b/app/views/idv/doc_auth/document_capture.html.erb @@ -1,4 +1,5 @@ <% if Figaro.env.acuant_sdk_document_capture_enabled == 'true' %> + <% title t('doc_auth.titles.doc_auth') %> <% content_for :meta_tags do %> <% end %> -
- <%= javascript_pack_tag 'document-capture' %> -<% end %> -
-<% title t('doc_auth.titles.doc_auth') %> - -<%= render 'idv/doc_auth/error_messages', flow_session: flow_session %> - -

- <% if liveness_checking_enabled? %> - <%= t('doc_auth.headings.document_capture_with_selfie_html') %> - <% else %> - <%= t('doc_auth.headings.document_capture_html') %> - <% end %> -

- -<%= render 'idv/doc_auth/document_capture_notices', flow_session: flow_session %> - -<%= simple_form_for( - :doc_auth, - url: url_for, - method: 'PUT', - html: { autocomplete: 'off', role: 'form', class: 'mt2' } -) do |f| %> - <%# ---- Front Image ----- %> - -
-
-

- <%= t('doc_auth.headings.upload_front_html') %> -

- - <%= accordion('totp-info', t('doc_auth.tips.title_html'), - wrapper_css: 'my2 col-12 fs-16p') do %> - <%= render 'idv/doc_auth/tips_and_sample' %> -
- <%= image_tag(asset_url('state-id-sample-front.jpg'), height: 338, width: 450) %> +
+ <%= render 'idv/doc_auth/error_messages', flow_session: flow_session %> + +

+ <% if liveness_checking_enabled? %> + <%= t('doc_auth.headings.document_capture_heading_with_selfie_html') %> + <% else %> + <%= t('doc_auth.headings.document_capture_heading_html') %> + <% end %> +

+ + <%= simple_form_for( + :doc_auth, + url: url_for, + method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' } + ) do |f| %> + <%# ---- Front Image ----- %> + +

<%= t('doc_auth.tips.document_capture_header_text') %>

+
    +
  • <%= t('doc_auth.tips.document_capture_id_text1') %>
  • +
  • <%= t('doc_auth.tips.document_capture_id_text2') %>
  • +
  • <%= t('doc_auth.tips.document_capture_id_text3') %>
  • +
  • <%= t('doc_auth.tips.document_capture_id_text4') %>
  • +
+ +
+

+ + <%= t('doc_auth.headings.document_capture_front') %>
+
+ <%= t('doc_auth.tips.document_capture_hint') %> +

+ + <%= f.input :front_image_data_url, as: :hidden %> + <%= f.input :front_image, label: false, as: :file, required: true %> +
- <% end %> - <%= f.input :front_image_data_url, as: :hidden %> - <%= f.input :front_image, label: false, as: :file, required: true, wrapper_class: 'mt3 sm-col-8' %> -
-
- - <%# ---- Back Image ----- %> + <%# ---- Back Image ----- %> -
-
-

- <%= t('doc_auth.headings.upload_back_html') %> -

+
+

+ + <%= t('doc_auth.headings.document_capture_back') %>
+
+ <%= t('doc_auth.tips.document_capture_hint') %> +

- <%= accordion('totp-info', t('doc_auth.tips.title_html'), - wrapper_css: 'my2 col-12 fs-16p') do %> - <%= render 'idv/doc_auth/tips_and_sample' %> -
- <%= image_tag(asset_url('state-id-sample-back.jpg'), height: 338, width: 450) %> + <%= f.input :back_image_data_url, as: :hidden %> + <%= f.input :back_image, label: false, as: :file, required: true %> +
- <% end %> - <%= f.input :back_image_data_url, as: :hidden %> - <%= f.input :back_image, label: false, as: :file, required: true, wrapper_class: 'mt3 sm-col-8' %> -
-
- - <%# ---- Selfie ----- %> - <% if liveness_checking_enabled? %> -
-
-

- <%= t('doc_auth.headings.selfie') %> -

- - <%= f.input :selfie_image_data_url, as: :hidden %> - <%= f.input :selfie_image, label: false, as: :file, required: true, wrapper_class: 'mt3 sm-col-8' %> -
-
- <% end %> + <%# ---- Selfie ----- %> + <% if liveness_checking_enabled? %> +
+
+

<%= t('doc_auth.instructions.document_capture_selfie_instructions') %>

+ +

<%= t('doc_auth.tips.document_capture_header_text') %>

+
    +
  • <%= t('doc_auth.tips.document_capture_selfie_text1') %>
  • +
  • <%= t('doc_auth.tips.document_capture_selfie_text2') %>
  • +
  • <%= t('doc_auth.tips.document_capture_selfie_text3') %>
  • +
+ +

+ + <%= t('doc_auth.headings.document_capture_selfie') %>
+
+ <%= t('doc_auth.tips.document_capture_hint') %> +

+ + <%= f.input :selfie_image_data_url, as: :hidden %> + <%= f.input :selfie_image, label: false, as: :file, required: true %> +
+
+ <% end %> + + <%# ---- Submit ----- %> + +
+ <%= render 'idv/doc_auth/submit_with_spinner' %> +
+ <% end %> <%# end simple_form_for %> - <%# ---- Submit ----- %> +

<%= t('doc_auth.info.document_capture_upload_image') %>

-
- <%= render 'idv/doc_auth/submit_with_spinner' %> + <%= javascript_pack_tag 'image-preview' %>
+ <%= render 'idv/doc_auth/start_over_or_cancel' %> + <% unless Figaro.env.document_capture_react_enabled == 'false' %> + <%= javascript_pack_tag 'document-capture' %> + <% end %> <% end %> - -

<%= t('doc_auth.info.upload_image') %>

- -<%= render 'idv/doc_auth/start_over_or_cancel' %> -<%= javascript_pack_tag 'image-preview' %> -
diff --git a/app/views/idv/doc_auth/front_image.html.erb b/app/views/idv/doc_auth/front_image.html.erb index 1aed7846afe..b71c99bd4ab 100644 --- a/app/views/idv/doc_auth/front_image.html.erb +++ b/app/views/idv/doc_auth/front_image.html.erb @@ -5,11 +5,10 @@ <%= render 'idv/doc_auth/front_of_state_id_image' %>

- <%= t('doc_auth.headings.upload_front') %> +

-<%= accordion('totp-info', t('doc_auth.tips.title_html'), - wrapper_css: 'my2 col-12 fs-16p') do %> +<%= accordion('totp-info', t('doc_auth.tips.title_html')) do %> <%= render 'idv/doc_auth/tips_and_sample' %>
<%= image_tag(asset_url('state-id-sample-front.jpg'), height: 338, width: 450) %> diff --git a/app/views/idv/doc_auth/link_sent.html.erb b/app/views/idv/doc_auth/link_sent.html.erb index 36a868e2e16..4c65d01f40d 100644 --- a/app/views/idv/doc_auth/link_sent.html.erb +++ b/app/views/idv/doc_auth/link_sent.html.erb @@ -15,7 +15,7 @@
- <%= image_tag asset_url('idv/phone.png') %> + <%= image_tag asset_url('idv/phone.png'), alt: t('image_description.camera_mobile_phone') %>

<%= t('doc_auth.info.link_sent').first %>

diff --git a/app/views/idv/doc_auth/mobile_document_capture.html.erb b/app/views/idv/doc_auth/mobile_document_capture.html.erb new file mode 100644 index 00000000000..14fb819714e --- /dev/null +++ b/app/views/idv/doc_auth/mobile_document_capture.html.erb @@ -0,0 +1,109 @@ +<% if Figaro.env.acuant_sdk_document_capture_enabled == 'true' %> + <% title t('doc_auth.titles.doc_auth') %> + <% content_for :meta_tags do %> + + + <% end %> +
+ <%= render 'idv/doc_auth/error_messages', flow_session: flow_session %> + +

+ <% if liveness_checking_enabled? %> + <%= t('doc_auth.headings.document_capture_heading_with_selfie_html') %> + <% else %> + <%= t('doc_auth.headings.document_capture_heading_html') %> + <% end %> +

+ + <%= simple_form_for( + :doc_auth, + url: url_for, + method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'mt2' } + ) do |f| %> + <%# ---- Front Image ----- %> + +

<%= t('doc_auth.tips.document_capture_header_text') %>

+
    +
  • <%= t('doc_auth.tips.document_capture_id_text1') %>
  • +
  • <%= t('doc_auth.tips.document_capture_id_text2') %>
  • +
  • <%= t('doc_auth.tips.document_capture_id_text3') %>
  • +
  • <%= t('doc_auth.tips.document_capture_id_text4') %>
  • +
+ +
+

+ + <%= t('doc_auth.headings.document_capture_front') %>
+
+ <%= t('doc_auth.tips.document_capture_hint') %> +

+ + <%= f.input :front_image_data_url, as: :hidden %> + <%= f.input :front_image, label: false, as: :file, required: true %> +
+
+ + <%# ---- Back Image ----- %> + +
+

+ + <%= t('doc_auth.headings.document_capture_back') %>
+
+ <%= t('doc_auth.tips.document_capture_hint') %> +

+ + <%= f.input :back_image_data_url, as: :hidden %> + <%= f.input :back_image, label: false, as: :file, required: true %> +
+
+ + <%# ---- Selfie ----- %> + <% if liveness_checking_enabled? %> +
+
+

<%= t('doc_auth.instructions.document_capture_selfie_instructions') %>

+ +

<%= t('doc_auth.tips.document_capture_header_text') %>

+
    +
  • <%= t('doc_auth.tips.document_capture_selfie_text1') %>
  • +
  • <%= t('doc_auth.tips.document_capture_selfie_text2') %>
  • +
  • <%= t('doc_auth.tips.document_capture_selfie_text3') %>
  • +
+ +

+ + <%= t('doc_auth.headings.document_capture_selfie') %>
+
+ <%= t('doc_auth.tips.document_capture_hint') %> +

+ + <%= f.input :selfie_image_data_url, as: :hidden %> + <%= f.input :selfie_image, label: false, as: :file, required: true %> +
+
+ <% end %> + + <%# ---- Submit ----- %> + +
+ <%= render 'idv/doc_auth/submit_with_spinner' %> +
+ <% end %> <%# end simple_form_for %> + +

<%= t('doc_auth.info.document_capture_upload_image') %>

+ + <%= javascript_pack_tag 'image-preview' %> +
+ <%= render 'idv/doc_auth/start_over_or_cancel' %> + <% unless Figaro.env.document_capture_react_enabled == 'false' %> + <%= javascript_pack_tag 'document-capture' %> + <% end %> +<% end %> diff --git a/app/views/idv/doc_auth/upload.html.erb b/app/views/idv/doc_auth/upload.html.erb index ba4dbf63f62..c62d48b6795 100644 --- a/app/views/idv/doc_auth/upload.html.erb +++ b/app/views/idv/doc_auth/upload.html.erb @@ -24,8 +24,8 @@
<%= image_tag( - asset_url('idv/phone.png', - alt: t('image_description.camera_mobile_phone')), + asset_url('idv/phone.png'), + alt: t('image_description.camera_mobile_phone'), width: 80, ) %>
diff --git a/app/views/shared/_accordion.html.erb b/app/views/shared/_accordion.html.erb new file mode 100644 index 00000000000..d1f1e5bacdf --- /dev/null +++ b/app/views/shared/_accordion.html.erb @@ -0,0 +1,15 @@ +
+ <<%= options.fetch(:heading_level, 'div')%> class="usa-accordion__heading"> + + > +
+
+ <%= yield %> +
+ <% unless options[:hide_close_link] %> + + <% end %> +
+
diff --git a/app/views/shared/_accordion.html.slim b/app/views/shared/_accordion.html.slim deleted file mode 100644 index e0f8f4685be..00000000000 --- a/app/views/shared/_accordion.html.slim +++ /dev/null @@ -1,35 +0,0 @@ -.accordion( - class=(options[:wrapper_css] || 'mb4 col-12 fs-16p') -) - .accordion-header( - class=('display-none' if options[:hide_header]) - role="button" - aria-labelledby=target_id - ) - .accordion-header-controls.py1.px2.mt-tiny.mb-tiny( - aria-controls="#{target_id}" - aria-expanded="true" - role="button" - tabindex="0" - ) - span.mb0.mr2 - = header_text - = image_tag asset_url('plus.svg'), alt: t('image_description.accordian_plus_buttom'), - width: 16, class: 'plus-icon display-none' - = image_tag asset_url('minus.svg'), alt: t('image_description.accordian_minus_buttom'), - width: 16, class: 'minus-icon display-none' - .accordion-content.clearfix.pt1( - id="#{target_id}" - role="region" - aria-hidden="true" - ) - .px2 - = yield - - unless options[:hide_close_link] - .py1.accordion-footer( - aria-controls="#{target_id}" - tabindex="0" - ) - .pb-tiny.pt-tiny - = image_tag(asset_url('up-carat-thin.svg'), width: 14, class: 'mr1') - = t('users.personal_key.close') diff --git a/app/views/shared/_failure.html.erb b/app/views/shared/_failure.html.erb index 06854c8c584..5a1af2dbd29 100644 --- a/app/views/shared/_failure.html.erb +++ b/app/views/shared/_failure.html.erb @@ -1,6 +1,6 @@ <% title presenter.title %> -<%= image_tag(asset_url(presenter.state_icon), width: 54) %> +<%= image_tag(asset_url(presenter.state_icon), alt: '', width: 54) %>

<%= presenter.header %> diff --git a/app/views/shared/_footer_lite.html.erb b/app/views/shared/_footer_lite.html.erb new file mode 100644 index 00000000000..e2f9d6376c9 --- /dev/null +++ b/app/views/shared/_footer_lite.html.erb @@ -0,0 +1,72 @@ +<% + show_language_dropdown = I18n.available_locales.count > 1 + sanitized_requested_url = request.query_parameters.slice(:request_id) +%> + +
+ <% if show_language_dropdown %> +
+
+
+ <%= link_to('#', class: 'block text-decoration-none blue fs-13p', 'aria-expanded': 'false') do %> + <%= image_tag asset_url('globe-blue.svg'), width: 12, class: 'mr1', alt: '', + 'aria-hidden': 'true' %><%= t('i18n.language') %> + <% end %> +
+
+
+ + <% end %> +
'> +
+
+ <%= link_to('https://gsa.gov', class: 'flex flex-center text-decoration-none white h6', target: '_blank', 'aria-label': t('shared.footer_lite.gsa')) do %> + <%= image_tag asset_url('sp-logos/square-gsa.svg'), + width: 20, class: 'mr1 sm-show', alt: '' %> + <%= image_tag asset_url('sp-logos/square-gsa-dark.svg'), + width: 20, class: 'mr1 sm-hide', alt: '' %> + + <%= t('shared.footer_lite.gsa') %> + + <% end %> +
+
+ <% if show_language_dropdown %> +
    +
  • + <%= link_to '#', class: 'white text-decoration-none border border-blue rounded-lg px1 py-tiny', 'aria-expanded': 'false' do %> + <%= image_tag asset_url('globe-white.svg'), width: 12, class: 'mr1', alt: '', + 'aria-hidden': 'true' %> + <%= t('i18n.language') %> + + <% end %> + +
  • +
+ <% end %> + <%= link_to t('links.help'), MarketingSite.help_url, + class: 'caps h6 blue sm-white text-decoration-none mr3', target: '_blank' %> + <%= link_to t('links.contact'), MarketingSite.contact_url, + class: 'caps h6 blue sm-white text-decoration-none mr3', target: '_blank' %> + <%= link_to t('links.privacy_policy'), MarketingSite.privacy_url, + class: 'caps h6 blue sm-white text-decoration-none', target: '_blank' %> +
+
+
+
diff --git a/app/views/shared/_footer_lite.html.slim b/app/views/shared/_footer_lite.html.slim deleted file mode 100644 index 4a41b92d5d6..00000000000 --- a/app/views/shared/_footer_lite.html.slim +++ /dev/null @@ -1,59 +0,0 @@ -- show_language_dropdown = I18n.available_locales.count > 1 -- sanitized_requested_url = request.query_parameters.slice(:request_id) - -footer.footer.bg-light-blue.sm-bg-navy - - if show_language_dropdown - .sm-hide.border-bottom - .container.cntnr-wide.py1.px2.lg-px0.h5 - .i18n-mobile-toggle.center - = link_to '#', class: 'block text-decoration-none blue fs-13p', - 'aria-expanded': 'false' do - = image_tag asset_url('globe-blue.svg'), width: 12, class: 'mr1', alt: '', - 'aria-hidden': 'true' - = t('i18n.language') - span.caret.inline-block.ml-tiny(aria-hidden="true") - | ▾ - .i18n-mobile-dropdown.sm-hide.display-none - ul.list-reset.mb0.white.center - - I18n.available_locales.each do |locale| - li.border-bottom - = link_to t("i18n.locale.#{locale}"), - sanitized_requested_url.merge(locale: locale), - class: 'block py-12p px2 text-decoration-none blue fs-13p' - - .container.py1.px2.lg-px0(class="#{'sm-py0' if show_language_dropdown}") - .flex.flex-center - .flex.flex-auto.flex-first - = link_to('https://gsa.gov', - class: 'flex flex-center text-decoration-none white h6', - target: '_blank') do - = image_tag asset_url('sp-logos/square-gsa.svg'), - width: 20, class: 'mr1 sm-show', alt: '' - = image_tag asset_url('sp-logos/square-gsa-dark.svg'), - width: 20, class: 'mr1 sm-hide', alt: '' - span.sm-show - = t('shared.footer_lite.gsa') - .flex.flex-center - - if show_language_dropdown - ul.list-reset.sm-show.mb0 - li.i18n-desktop-toggle.flex.my1.mx3.relative - = link_to '#', - class: 'white text-decoration-none border border-blue rounded-lg px1 py-tiny', - 'aria-expanded': 'false' do - = image_tag asset_url('globe-white.svg'), width: 12, class: 'mr1', alt: '', - 'aria-hidden': 'true' - = t('i18n.language') - span.caret.inline-block.ml-tiny(aria-hidden="true") - | ▾ - ul.i18n-desktop-dropdown.list-reset.mb0.white.display-none - - I18n.available_locales.each do |locale| - li.border-bottom.border-navy - = link_to t("i18n.locale.#{locale}"), - sanitized_requested_url.merge(locale: locale), - class: 'block pl-24p py2 text-decoration-none white' - = link_to t('links.help'), MarketingSite.help_url, - class: 'caps h6 blue sm-white text-decoration-none mr3', target: '_blank' - = link_to t('links.contact'), MarketingSite.contact_url, - class: 'caps h6 blue sm-white text-decoration-none mr3', target: '_blank' - = link_to t('links.privacy_policy'), MarketingSite.privacy_url, - class: 'caps h6 blue sm-white text-decoration-none', target: '_blank' diff --git a/app/views/users/delete/show.html.erb b/app/views/users/delete/show.html.erb index 06070167938..e7401d4c663 100644 --- a/app/views/users/delete/show.html.erb +++ b/app/views/users/delete/show.html.erb @@ -1,7 +1,7 @@


<%= t('users.delete.subheading') %> diff --git a/app/views/users/webauthn_setup/new.html.erb b/app/views/users/webauthn_setup/new.html.erb index 52d99e72020..3d6a7c5604c 100644 --- a/app/views/users/webauthn_setup/new.html.erb +++ b/app/views/users/webauthn_setup/new.html.erb @@ -21,10 +21,9 @@ <%= hidden_field_tag :webauthn_public_key, '', id: 'webauthn_public_key' %> <%= hidden_field_tag :attestation_object, '', id: 'attestation_object' %> <%= hidden_field_tag :client_data_json, '', id: 'client_data_json' %> - <%= label_tag 'code', t('forms.webauthn_setup.nickname'), class: 'block bold' %> + <%= label_tag 'code', t('forms.webauthn_setup.nickname'), class: 'block bold', for: 'nickname' %> <%= text_field_tag :name, '', required: true, id: 'nickname', - class: 'block col-12 field monospace', size: 16, maxlength: 20, - 'aria-labelledby': 'totp-label' %> + class: 'block col-12 field monospace', size: 16, maxlength: 20 %>
<%= hidden_field_tag 'remember_device', false, id: 'remember_device_preference' %> <%= check_box_tag 'remember_device', true, @presenter.remember_device_box_checked?, class: 'my2 ml2 mr1' %> diff --git a/config/application.yml.default b/config/application.yml.default index cb5c5222670..e973e7c0fea 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -48,6 +48,7 @@ disallow_ial2_recovery: doc_capture_request_valid_for_minutes: '15' doc_auth_extend_timeout_by_minutes: '40' document_capture_step_enabled: 'false' +document_capture_react_enabled: 'true' email_from: no-reply@login.gov enable_load_testing_mode: 'false' event_disavowal_expiration_hours: '240' diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index 092e6f48a1e..d840943fd57 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -12,7 +12,7 @@ en: forms: address1: Address change_file: Change file - choose_file: Drag file here or choose from folder + choose_file_html: Drag file here or choose from folder city: City dob: Date of Birth doc_success: We've verified your social security number and state-issued ID. @@ -25,9 +25,13 @@ en: headings: address: Mailing Address capture_complete: We have verified your state issued ID - document_capture_html: Upload your state‑issued ID - document_capture_with_selfie_html: Upload your state‑issued ID and - a photo of you + document_capture: Add your state-issued ID + document_capture_back: Back of your ID + document_capture_front: Front of your ID + document_capture_heading_html: Add your state‑issued ID + document_capture_heading_with_selfie_html: Add your state‑issued ID + and selfie + document_capture_selfie: Your photo selfie: Take a selfie. ssn: Please enter your social security number. take_pic_back: Take a photo of the back of your ID @@ -36,14 +40,14 @@ en: text_message: We sent a message to your phone upload: How would you like to upload your state issued ID? upload_back: Upload an image of the back of your state issued ID - upload_back_html: Upload an image of the back of your state‑issued ID upload_from_phone: Take a photo with a mobile phone to upload your ID upload_front: Upload an image of the front of your state issued ID - upload_front_html: Upload an image of the front of your state‑issued ID verify: Please verify your information welcome: We need to verify your identity info: camera_required: Your mobile phone must have a camera and a web browser + document_capture_upload_image: We only use your ID to verify your identity, + and we will not save any images. link_sent: - Please check your phone and follow instructions to take a photo of your state issued ID. @@ -72,6 +76,8 @@ en: and share your personal information. We will only use it to verify your identity. document_capture_fallback_html: Having trouble? %{link} document_capture_fallback_link: Click here to upload an image + document_capture_selfie_instructions: Now take a picture of yourself. We'll + compare it to the image on the front of your ID. email_sent: Link sent to %{email}. Please check your desktop email and follow instructions to verify your identity. learn_more: Learn more. @@ -89,6 +95,16 @@ en: text4: make sure you always have access welcome: 'What you''ll need to do:' tips: + document_capture_header_text: 'For best results:' + document_capture_hint: Must be a JPG, BMP, PNG, or TIFF + document_capture_id_text1: Use a dark background + document_capture_id_text2: Take the photo on a flat surface + document_capture_id_text3: Do not use the flash on your camera + document_capture_id_text4: File size should be at least 2 MB + document_capture_selfie_text1: Face the camera and ensure your entire head is + in the photo + document_capture_selfie_text2: Take a photo against a plain background + document_capture_selfie_text3: Do not wear a hat or sunglasses header_text: Guidelines for taking a photo of your ID text1: Take it in a room with lots of light. Indirect sunlight is best. text2: Make sure your ID doesn't have dirt or damaged barcodes. @@ -100,9 +116,7 @@ en: information. text7: Use a high-resolution camera. A good mobile phone or tablet camera will work. - title: Don't take the photo on a white surface! title_html: "Don't take the photo on a white surface!    See more tips..." - title_more: See more tips… titles: doc_auth: Document Authentication diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml index 53afdd42885..60e139b9ddf 100644 --- a/config/locales/doc_auth/es.yml +++ b/config/locales/doc_auth/es.yml @@ -12,7 +12,7 @@ es: forms: address1: Dirección change_file: Cambiar archivo - choose_file: Arrastre el archivo aquí o elija de la carpeta + choose_file_html: Arrastre el archivo aquí o elija de la carpeta city: Ciudad dob: Fecha de nacimiento doc_success: Verificamos su número de seguro social y su identificación emitida @@ -26,9 +26,13 @@ es: headings: address: Dirección de envio capture_complete: Hemos verificado la identificación emitida por su estado - document_capture_html: Cargue su identificación emitida por el estado - document_capture_with_selfie_html: Cargue su identificación emitida por el estado - y una foto suya + document_capture: Agregue su identificación emitida por el estado + document_capture_back: Detrás de su identificación + document_capture_front: Frente de su identificación + document_capture_heading_html: Cargue su identificación emitida por el estado + document_capture_heading_with_selfie_html: Cargue su identificación emitida + por el estado y una foto suya + document_capture_selfie: Tu foto selfie: Toma una selfie. ssn: Por favor ingrese su número de seguro social. take_pic_back: Toma una foto de la parte posterior de tu identificación @@ -37,17 +41,15 @@ es: text_message: Enviamos un mensaje a su teléfono upload: "¿Cómo te gustaría subir tu identificación emitida por el estado?" upload_back: Cargue una foto del dorso de su identificación emitida por el estado. - upload_back_html: Cargue una foto del dorso de su identificación emitida por - el estado. upload_from_phone: Tome una foto con un teléfono móvil para cargar su identificación upload_front: Cargue una foto del frente de su identificación emitida por el estado. - upload_front_html: Cargue una foto del frente de su identificación emitida por - el verify: Por favor verifica tu información welcome: Nosotros necesitamos verificar tu identidad info: camera_required: Su teléfono móvil debe tener una cámara y un navegador web + document_capture_upload_image: Solo utilizamos su ID para verificar su identidad + y no guardaremos ninguna imagen. link_sent: - Verifique su teléfono y siga las instrucciones para tomar una fotografía de la identificación emitida por su estado. @@ -78,6 +80,8 @@ es: su identidad. document_capture_fallback_html: "¿Teniendo problemas? %{link}" document_capture_fallback_link: Haga clic aquí para cargar una imagen. + document_capture_selfie_instructions: Ahora toma una foto de ti mismo. Lo compararemos + con la imagen en el frente de su identificación. email_sent: Enlace enviado a %{email}. Compruebe el correo electrónico de su escritorio y siga las instrucciones para verificar su identidad. learn_more: Aprende más. @@ -96,6 +100,16 @@ es: text4: asegúrate de tener siempre acceso welcome: 'Lo que necesitarás hacer:' tips: + document_capture_header_text: 'Para mejores resultados:' + document_capture_hint: Aceptamos los formatos BMP, PNG, TIFF y JPG. + document_capture_id_text1: Usa un fondo oscuro + document_capture_id_text2: Toma la foto sobre una superficie plana + document_capture_id_text3: No uses el flash en tu cámara + document_capture_id_text4: El tamaño del archivo debe ser de al menos 2 MB + document_capture_selfie_text1: Mira a la cámara y asegúrate de que toda tu cabeza + esté en la foto + document_capture_selfie_text2: Toma una foto sobre un fondo liso + document_capture_selfie_text3: No use sombrero o lentes de sol header_text: Pautas para tomar una foto de su identificación text1: Tómalo en una habitación con mucha luz. La luz solar indirecta es la mejor. @@ -109,8 +123,6 @@ es: la información. text7: Utilice una cámara de alta resolución. La cámara de un buen teléfono móvil o tableta funcionará. - title: "¡No tome la foto en una superficie blanca!" title_html: "¡No tome la foto en una superficie blanca! Ver más..." - title_more: Ver más… titles: doc_auth: Autenticación de documentos diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml index 72d4c2a1c02..2906b0ea405 100644 --- a/config/locales/doc_auth/fr.yml +++ b/config/locales/doc_auth/fr.yml @@ -12,7 +12,8 @@ fr: forms: address1: Adresse change_file: Changer de fichier - choose_file: Faites glisser le fichier ici ou choisissez dans un dossier + choose_file_html: Faites glisser le fichier ici ou choisissez + dans un dossier city: Ville dob: Date de naissance doc_success: Nous avons vérifié votre numéro de sécurité sociale et votre identifiant @@ -26,9 +27,14 @@ fr: headings: address: Adresse mail capture_complete: Nous avons vérifié votre ID émis par l'état - document_capture_html: Téléchargez votre pièce d'identité délivrée par l'État - document_capture_with_selfie_html: Téléchargez votre pièce d'identité officielle - et une photo de vous + document_capture: Ajoutez votre pièce d'identité émise par l'État + document_capture_back: Dos de votre pièce d'identité + document_capture_front: Recto de votre pièce d'identité + document_capture_heading_html: Téléchargez votre pièce d'identité délivrée par + l'État + document_capture_heading_with_selfie_html: Téléchargez votre pièce d'identité + officielle et une photo de vous + document_capture_selfie: Ta photo selfie: Prendre un selfie. ssn: S'il vous plaît entrez votre numéro de sécurité sociale. take_pic_back: Prenez une photo au verso de votre identifiant @@ -38,19 +44,17 @@ fr: upload: Comment voudriez-vous télécharger votre ID émis par l'état? upload_back: S'il vous plaît télécharger une photo du dos de votre ID émis par l'état. - upload_back_html: S'il vous plaît télécharger une photo du dos de votre ID émis - par l'état. upload_from_phone: Prenez une photo avec un téléphone portable pour télécharger votre pièce d'identité upload_front: Veuillez télécharger une photo du recto de votre identifiant émis par l'État. - upload_front_html: Veuillez télécharger une photo du recto de votre identifiant - émis par l'État. verify: S'il vous plaît vérifier vos informations welcome: Nous devons vérifier votre identité info: camera_required: Votre téléphone portable doit avoir une caméra et un navigateur Web + document_capture_upload_image: Nous n'utilisons votre identifiant que pour vérifier + votre identité, et nous n'enregistrerons aucune image. link_sent: - Veuillez vérifier votre téléphone et suivre les instructions pour prendre une photo de votre identité émise par l'État. @@ -84,6 +88,8 @@ fr: que pour vérifier votre identité. document_capture_fallback_html: Avoir des problèmes? %{link} document_capture_fallback_link: Cliquez ici pour télécharger une image + document_capture_selfie_instructions: Maintenant, prenez une photo de vous. + Nous le comparerons à l'image au recto de votre pièce d'identité. email_sent: Lien envoyé à %{email}. Veuillez vérifier votre email de bureau et suivez les instructions pour vérifier votre identité. learn_more: Apprendre encore plus. @@ -103,6 +109,16 @@ fr: text4: assurez-vous d'avoir toujours accès welcome: 'Ce que vous devez faire:' tips: + document_capture_header_text: 'Pour les meilleurs résultats:' + document_capture_hint: Nous acceptons les formats BMP, PNG, TIFF et JPG. + document_capture_id_text1: Utilisez un fond sombre + document_capture_id_text2: Prenez la photo sur une surface plane + document_capture_id_text3: Ne pas utiliser le flash sur votre appareil photo + document_capture_id_text4: La taille du fichier doit être d'au moins 2 Mo + document_capture_selfie_text1: Faites face à la caméra et assurez-vous que toute + votre tête est sur la photo + document_capture_selfie_text2: Prenez une photo sur un fond uni + document_capture_selfie_text3: Ne portez pas de chapeau ni de lunettes de soleil header_text: Directives pour prendre une photo de votre identité text1: Prenez-le dans une pièce très éclairée. La lumière solaire indirecte est la meilleure. @@ -117,8 +133,6 @@ fr: de lire toutes les informations. text7: Utilisez une caméra haute résolution. La caméra d'un bon téléphone mobile ou d'une tablette fonctionnera. - title: Ne prenez pas la photo sur une surface blanche! title_html: "Ne prenez pas la photo sur une surface blanche! Voir plus..." - title_more: Voir plus… titles: doc_auth: Authentification de document diff --git a/config/locales/image_description/en.yml b/config/locales/image_description/en.yml index d3356067d0e..d2769473198 100644 --- a/config/locales/image_description/en.yml +++ b/config/locales/image_description/en.yml @@ -1,8 +1,6 @@ --- en: image_description: - accordian_minus_buttom: Minus button - accordian_plus_buttom: Plus button camera_mobile_phone: Camera flashing on a mobile phone close: Close button spinner: Loading spinner diff --git a/config/locales/image_description/es.yml b/config/locales/image_description/es.yml index 82b94c798ef..584f2930615 100644 --- a/config/locales/image_description/es.yml +++ b/config/locales/image_description/es.yml @@ -1,8 +1,6 @@ --- es: image_description: - accordian_minus_buttom: Botón menos - accordian_plus_buttom: Botón más camera_mobile_phone: Cámara parpadeando en un teléfono móvil close: Botón de cierre spinner: Indicador de carga diff --git a/config/locales/image_description/fr.yml b/config/locales/image_description/fr.yml index ec598581fc8..ed6875d62f3 100644 --- a/config/locales/image_description/fr.yml +++ b/config/locales/image_description/fr.yml @@ -1,8 +1,6 @@ --- fr: image_description: - accordian_minus_buttom: Bouton moins - accordian_plus_buttom: Bouton Plus camera_mobile_phone: Appareil photo clignotant sur un téléphone mobile close: Bouton de fermeture spinner: Indicateur de chargement diff --git a/config/locales/instructions/en.yml b/config/locales/instructions/en.yml index b376ceea3d3..98575f722fb 100644 --- a/config/locales/instructions/en.yml +++ b/config/locales/instructions/en.yml @@ -61,11 +61,11 @@ en: confirm_code_html: Want us to call you again? %{resend_code_link} number_message_html: We just called you at %{number}. webauthn: + confirm_webauthn_html: Present the security key that you associated with your + account. confirm_webauthn_only_html: This app requires a higher level of security. You need to verify your identity using a security key that you previously set up to access your information. - confirm_webauthn_html: Present the security key that you associated with your - account. wrong_number_html: Entered the wrong phone number? %{link} password: forgot: Don’t know your password? Reset it after confirming your email address. diff --git a/config/service_providers.localdev.yml b/config/service_providers.localdev.yml index 3a92a18a79a..2575f765dd0 100644 --- a/config/service_providers.localdev.yml +++ b/config/service_providers.localdev.yml @@ -314,6 +314,7 @@ development: return_to_sp_url: 'http://localhost:3001' redirect_uris: - 'http://localhost:3001/auth/logindotgov/callback' + - 'http://localhost:3001' 'urn:gov:gsa:openidconnect:development': redirect_uris: diff --git a/db/migrate/20200803211123_add_acuant_result_to_proofing_costs.rb b/db/migrate/20200803211123_add_acuant_result_to_proofing_costs.rb new file mode 100644 index 00000000000..6b44d80627b --- /dev/null +++ b/db/migrate/20200803211123_add_acuant_result_to_proofing_costs.rb @@ -0,0 +1,10 @@ +class AddAcuantResultToProofingCosts < ActiveRecord::Migration[5.2] + def up + add_column :proofing_costs, :acuant_result_count, :integer + change_column_default :proofing_costs, :acuant_result_count, 0 + end + + def down + remove_column :proofing_costs, :acuant_result_count + end +end diff --git a/db/migrate/20200803211145_backfill_add_acuant_result_to_proofing_costs.rb b/db/migrate/20200803211145_backfill_add_acuant_result_to_proofing_costs.rb new file mode 100644 index 00000000000..3b7abae0e69 --- /dev/null +++ b/db/migrate/20200803211145_backfill_add_acuant_result_to_proofing_costs.rb @@ -0,0 +1,10 @@ +class BackfillAddAcuantResultToProofingCosts < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + ProofingCost.unscoped.in_batches do |relation| + relation.update_all acuant_result_count: 0 + sleep(0.01) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b423803ba02..831c5d1595e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_07_23_214611) do +ActiveRecord::Schema.define(version: 2020_08_03_211145) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -380,6 +380,7 @@ t.integer "phone_otp_count", default: 0 t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "acuant_result_count", default: 0 t.index ["user_id"], name: "index_proofing_costs_on_user_id", unique: true end diff --git a/lib/asset_checker.rb b/lib/asset_checker.rb index 74e7cb78b48..3893a4151cb 100644 --- a/lib/asset_checker.rb +++ b/lib/asset_checker.rb @@ -9,8 +9,8 @@ def self.check_files(argv) def self.file_has_missing?(file) data = File.open(file).read - missing_translations = find_missing(data, /\Wt\s?\(['"]([^'^"]*)['"]\)/, @translation_strings) - missing_assets = find_missing(data, /\WassetPath=["'](.*)['"]/, @asset_strings) + missing_translations = find_missing(data, /\Wt\s?\(['"]([^'"]*?)['"]\)/, @translation_strings) + missing_assets = find_missing(data, /\WassetPath=["'](.*?)['"]/, @asset_strings) has_missing = (missing_translations.any? || missing_assets.any?) if has_missing warn file diff --git a/package.json b/package.json index ba5a69e246a..ba440eee6f2 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,7 @@ "@babel/preset-react": "^7.10.4", "@babel/register": "^7.4.4", "@testing-library/user-event": "^12.0.11", - "atob": "^2.1.2", "babel-eslint": "^10.1.0", - "btoa": "^1.2.1", "chai": "^3.5.0", "dirty-chai": "^1.2.2", "eslint": "^7.4.0", diff --git a/spec/controllers/risc/security_events_controller_spec.rb b/spec/controllers/risc/security_events_controller_spec.rb index f4c63bf8067..696a5827158 100644 --- a/spec/controllers/risc/security_events_controller_spec.rb +++ b/spec/controllers/risc/security_events_controller_spec.rb @@ -27,7 +27,7 @@ subject: { subject_type: 'iss_sub', iss: root_url, - sub: identity.uuid, + sub: AgencyIdentityLinker.new(identity).link_identity.uuid, }, }, }, diff --git a/spec/features/accessibility/idv_pages_spec.rb b/spec/features/accessibility/idv_pages_spec.rb index 1f1fd2c475a..dab67d0261a 100644 --- a/spec/features/accessibility/idv_pages_spec.rb +++ b/spec/features/accessibility/idv_pages_spec.rb @@ -46,6 +46,29 @@ visit idv_path complete_all_doc_auth_steps click_idv_continue + fill_in :user_password, with: Features::SessionHelper::VALID_PASSWORD + click_continue + + expect(current_path).to eq idv_confirmations_path + expect(page).to be_accessible.according_to :section508, :"best-practice" + end + + scenario 'doc auth steps accessibility' do + sign_in_and_2fa_user + visit idv_path + complete_all_doc_auth_steps(expect_accessible: true) + click_idv_continue + fill_in :user_password, with: Features::SessionHelper::VALID_PASSWORD + click_continue + + expect(current_path).to eq idv_confirmations_path + expect(page).to be_accessible.according_to :section508, :"best-practice" + end + + scenario 'doc auth steps accessibility on mobile', driver: :headless_chrome_mobile do + sign_in_and_2fa_user + visit idv_path + complete_all_doc_auth_steps(expect_accessible: true) click_idv_continue fill_in :user_password, with: Features::SessionHelper::VALID_PASSWORD click_continue diff --git a/spec/features/accessibility/user_pages_spec.rb b/spec/features/accessibility/user_pages_spec.rb index 6b838b3d3bc..d77edafe6d8 100644 --- a/spec/features/accessibility/user_pages_spec.rb +++ b/spec/features/accessibility/user_pages_spec.rb @@ -138,4 +138,22 @@ expect(page).to be_accessible.according_to :section508, :"best-practice" end + + scenario 'device events page' do + user = sign_in_and_2fa_user + device = create(:device, user: user) + create(:event, user: user) + + visit account_events_path(id: device.id) + + expect(page).to be_accessible.according_to :section508, :"best-practice" + end + + scenario 'delete user page' do + sign_in_and_2fa_user + + visit account_delete_path + + expect(page).to be_accessible.according_to :section508, :"best-practice" + end end diff --git a/spec/features/accessibility/visitor_pages_spec.rb b/spec/features/accessibility/visitor_pages_spec.rb index f08cd2a6333..e99be638cf4 100644 --- a/spec/features/accessibility/visitor_pages_spec.rb +++ b/spec/features/accessibility/visitor_pages_spec.rb @@ -25,4 +25,10 @@ expect(page).to be_accessible.according_to :section508, :"best-practice" end + + scenario 'new user cancel registration page' do + visit sign_up_cancel_path + + expect(page).to be_accessible.according_to :section508, :"best-practice" + end end diff --git a/spec/features/idv/doc_auth/document_capture_step_spec.rb b/spec/features/idv/doc_auth/document_capture_step_spec.rb index d2994280e28..2fde83adce5 100644 --- a/spec/features/idv/doc_auth/document_capture_step_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'document capture step' do +feature 'doc auth document capture step' do include IdvStepHelper include DocAuthHelper include InPersonHelper @@ -9,10 +9,12 @@ let(:user) { user_with_2fa } let(:liveness_enabled) { 'false' } before do + allow(Figaro.env).to receive(:document_capture_react_enabled).and_return('false') allow(Figaro.env).to receive(:document_capture_step_enabled). and_return(document_capture_step_enabled) allow(Figaro.env).to receive(:liveness_checking_enabled). and_return(liveness_enabled) + allow(Figaro.env).to receive(:acuant_sdk_document_capture_enabled).and_return('true') sign_in_and_2fa_user(user) complete_doc_auth_steps_before_document_capture_step end @@ -33,14 +35,21 @@ it 'is on the correct_page' do expect(current_path).to eq(idv_doc_auth_document_capture_step) - expect(page).to have_content(render_html_string(t('doc_auth.headings.upload_front_html'))) - expect(page).to have_content(render_html_string(t('doc_auth.headings.upload_back_html'))) - expect(page).to have_content(t('doc_auth.headings.selfie')) + expect(page).to have_content(t('doc_auth.headings.document_capture_front')) + expect(page).to have_content(t('doc_auth.headings.document_capture_back')) + expect(page).to have_content(t('doc_auth.headings.document_capture_selfie')) end - it 'displays tips and sample images' do - expect(page).to have_content(I18n.t('doc_auth.tips.text1')) - expect(page).to have_css('img[src*=state-id-sample-front]') + it 'displays tips' do + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_header_text')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text1')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text2')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text3')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text4')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_hint')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text1')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text2')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text3')) end it 'proceeds to the next page with valid info' do @@ -138,9 +147,21 @@ it 'is on the correct_page, but does not show the selfie upload option' do expect(current_path).to eq(idv_doc_auth_document_capture_step) - expect(page).to have_content(render_html_string(t('doc_auth.headings.upload_front_html'))) - expect(page).to have_content(render_html_string(t('doc_auth.headings.upload_back_html'))) - expect(page).not_to have_content(t('doc_auth.headings.selfie')) + expect(page).to have_content(t('doc_auth.headings.document_capture_front')) + expect(page).to have_content(t('doc_auth.headings.document_capture_back')) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + end + + it 'displays tips' do + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_header_text')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text1')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text2')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text3')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text4')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_hint')) + expect(page).not_to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text1')) + expect(page).not_to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text2')) + expect(page).not_to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text3')) end it 'proceeds to the next page with valid info' do @@ -212,9 +233,4 @@ def next_step idv_doc_auth_ssn_step end - - def render_html_string(html_string) - rendered = Nokogiri::HTML.parse(html_string).text - strip_nbsp(rendered) - end end diff --git a/spec/features/idv/doc_auth/mobile_document_capture_step_spec.rb b/spec/features/idv/doc_auth/mobile_document_capture_step_spec.rb new file mode 100644 index 00000000000..7c046928c82 --- /dev/null +++ b/spec/features/idv/doc_auth/mobile_document_capture_step_spec.rb @@ -0,0 +1,236 @@ +require 'rails_helper' + +feature 'doc auth mobile document capture step' do + include IdvStepHelper + include DocAuthHelper + include InPersonHelper + + let(:max_attempts) { Figaro.env.acuant_max_attempts.to_i } + let(:user) { user_with_2fa } + let(:liveness_enabled) { 'false' } + before do + allow(Figaro.env).to receive(:document_capture_react_enabled).and_return('false') + allow(Figaro.env).to receive(:document_capture_step_enabled). + and_return(document_capture_step_enabled) + allow(Figaro.env).to receive(:liveness_checking_enabled). + and_return(liveness_enabled) + allow(Figaro.env).to receive(:acuant_sdk_document_capture_enabled).and_return('true') + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_mobile_document_capture_step + end + + context 'when the step is disabled' do + let(:document_capture_step_enabled) { 'false' } + + it 'takes the user to the mobile front image step' do + expect(current_path).to eq(idv_doc_auth_mobile_front_image_step) + end + end + + context 'when the step is enabled' do + let(:document_capture_step_enabled) { 'true' } + + context 'when liveness checking is enabled' do + let(:liveness_enabled) { 'true' } + + it 'is on the correct_page' do + expect(current_path).to eq(idv_doc_auth_mobile_document_capture_step) + expect(page).to have_content(t('doc_auth.headings.document_capture_front')) + expect(page).to have_content(t('doc_auth.headings.document_capture_back')) + expect(page).to have_content(t('doc_auth.headings.document_capture_selfie')) + end + + it 'displays tips' do + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_header_text')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text1')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text2')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text3')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text4')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_hint')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text1')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text2')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text3')) + end + + it 'proceeds to the next page with valid info' do + attach_images + click_idv_continue + + expect(page).to have_current_path(next_step) + end + + it 'allows the use of a base64 encoded data url representation of the image' do + attach_front_image_data_url + attach_back_image_data_url + attach_selfie_image_data_url + click_idv_continue + + expect(page).to have_current_path(next_step) + expect(DocAuthMock::DocAuthMockClient.last_uploaded_front_image).to eq( + doc_auth_front_image_data_url_data, + ) + expect(DocAuthMock::DocAuthMockClient.last_uploaded_back_image).to eq( + doc_auth_back_image_data_url_data, + ) + expect(DocAuthMock::DocAuthMockClient.last_uploaded_selfie_image).to eq( + doc_auth_selfie_image_data_url_data, + ) + end + + it 'does not proceed to the next page with invalid info' do + mock_general_doc_auth_client_error(:create_document) + attach_images + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_mobile_document_capture_step) + end + + it 'offers in person option on failure' do + enable_in_person_proofing + + expect(page).to_not have_link(t('in_person_proofing.opt_in_link'), + href: idv_in_person_welcome_step) + + mock_general_doc_auth_client_error(:create_document) + attach_images + click_idv_continue + + expect(page).to have_link(t('in_person_proofing.opt_in_link'), + href: idv_in_person_welcome_step) + end + + it 'throttles calls to acuant and allows retry after the attempt window' do + allow(Figaro.env).to receive(:acuant_max_attempts).and_return(max_attempts) + max_attempts.times do + attach_images + click_idv_continue + + expect(page).to have_current_path(next_step) + click_on t('doc_auth.buttons.start_over') + complete_doc_auth_steps_before_mobile_document_capture_step + end + + attach_images + click_idv_continue + + expect(page).to have_current_path(idv_session_errors_throttled_path) + + Timecop.travel(Figaro.env.acuant_attempt_window_in_minutes.to_i.minutes.from_now) do + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_mobile_document_capture_step + attach_images + click_idv_continue + + expect(page).to have_current_path(next_step) + end + end + + it 'catches network connection errors on post_front_image' do + DocAuthMock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: Acuant::Response.new( + success: false, + errors: [I18n.t('errors.doc_auth.acuant_network_error')], + ), + ) + + attach_images + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_mobile_document_capture_step) + expect(page).to have_content(I18n.t('errors.doc_auth.acuant_network_error')) + end + end + + context 'when liveness checking is not enabled' do + let(:liveness_enabled) { 'false' } + + it 'is on the correct_page, but does not show the selfie upload option' do + expect(current_path).to eq(idv_doc_auth_mobile_document_capture_step) + expect(page).to have_content(t('doc_auth.headings.document_capture_front')) + expect(page).to have_content(t('doc_auth.headings.document_capture_back')) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + end + + it 'displays tips' do + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_header_text')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text1')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text2')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text3')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_id_text4')) + expect(page).to have_content(I18n.t('doc_auth.tips.document_capture_hint')) + expect(page).not_to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text1')) + expect(page).not_to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text2')) + expect(page).not_to have_content(I18n.t('doc_auth.tips.document_capture_selfie_text3')) + end + + it 'proceeds to the next page with valid info' do + attach_images(liveness_enabled: false) + click_idv_continue + + expect(page).to have_current_path(next_step) + end + + it 'allows the use of a base64 encoded data url representation of the image' do + attach_front_image_data_url + attach_back_image_data_url + click_idv_continue + + expect(page).to have_current_path(next_step) + expect(DocAuthMock::DocAuthMockClient.last_uploaded_front_image).to eq( + doc_auth_front_image_data_url_data, + ) + expect(DocAuthMock::DocAuthMockClient.last_uploaded_back_image).to eq( + doc_auth_back_image_data_url_data, + ) + expect(DocAuthMock::DocAuthMockClient.last_uploaded_selfie_image).to be_nil + end + + it 'throttles calls to acuant and allows retry after the attempt window' do + allow(Figaro.env).to receive(:acuant_max_attempts).and_return(max_attempts) + max_attempts.times do + attach_images(liveness_enabled: false) + click_idv_continue + + expect(page).to have_current_path(next_step) + click_on t('doc_auth.buttons.start_over') + complete_doc_auth_steps_before_mobile_document_capture_step + end + + attach_images(liveness_enabled: false) + click_idv_continue + + expect(page).to have_current_path(idv_session_errors_throttled_path) + + Timecop.travel(Figaro.env.acuant_attempt_window_in_minutes.to_i.minutes.from_now) do + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_mobile_document_capture_step + attach_images(liveness_enabled: false) + click_idv_continue + + expect(page).to have_current_path(next_step) + end + end + + it 'catches network connection errors on post_front_image' do + DocAuthMock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: Acuant::Response.new( + success: false, + errors: [I18n.t('errors.doc_auth.acuant_network_error')], + ), + ) + + attach_images(liveness_enabled: false) + click_idv_continue + + expect(page).to have_current_path(idv_doc_auth_mobile_document_capture_step) + expect(page).to have_content(I18n.t('errors.doc_auth.acuant_network_error')) + end + end + end + + def next_step + idv_doc_auth_ssn_step + end +end diff --git a/spec/features/reports/proofing_costs_report_spec.rb b/spec/features/reports/proofing_costs_report_spec.rb index a971fa403c7..1fdb2b913fb 100644 --- a/spec/features/reports/proofing_costs_report_spec.rb +++ b/spec/features/reports/proofing_costs_report_spec.rb @@ -4,7 +4,7 @@ include IdvStepHelper include DocAuthHelper - let(:subject) { Reports::ProofingCostsReport } + let(:report) { JSON.parse(Reports::ProofingCostsReport.new.call) } let(:user) { create(:user, :signed_up) } let(:user2) { create(:user, :signed_up) } let(:summary1) do @@ -21,6 +21,7 @@ { 'acuant_front_image_count_average' => 1.0, 'acuant_back_image_count_average' => 1.0, + 'acuant_result_count_average' => 1.0, 'aamva_count_average' => 1.0, 'lexis_nexis_resolution_count_average' => 1.0, 'gpo_letter_count_average' => 0.0, @@ -30,14 +31,14 @@ end it 'works for no records' do - expect(JSON.parse(subject.new.call)).to eq({}) + expect(report).to eq({}) end it 'works for one flow' do sign_in_and_2fa_user(user) complete_doc_auth_steps_before_doc_success_step - expect(JSON.parse(subject.new.call)).to eq(doc_success_funnel.merge(summary1)) + expect(report).to eq(doc_success_funnel.merge(summary1)) end it 'works for two flows' do @@ -46,6 +47,6 @@ sign_in_and_2fa_user(user2) complete_doc_auth_steps_before_doc_success_step - expect(JSON.parse(subject.new.call)).to eq(doc_success_funnel.merge(summary2)) + expect(report).to eq(doc_success_funnel.merge(summary2)) end end diff --git a/spec/features/saml/ial1_sso_spec.rb b/spec/features/saml/ial1_sso_spec.rb index 55d4dac2a16..0b5d6ead4f4 100644 --- a/spec/features/saml/ial1_sso_spec.rb +++ b/spec/features/saml/ial1_sso_spec.rb @@ -64,7 +64,7 @@ expect(current_url).to match new_user_session_path expect(page).to have_content(sp_content) - expect(page).to_not have_css('.accordion-header') + expect(page).to_not have_css('.usa-accordion__heading') end it 'shows user the start page with a link back to the SP' do diff --git a/spec/forms/security_event_form_spec.rb b/spec/forms/security_event_form_spec.rb index 75d8cd75081..eb6f1e2657f 100644 --- a/spec/forms/security_event_form_spec.rb +++ b/spec/forms/security_event_form_spec.rb @@ -6,7 +6,8 @@ subject(:form) { SecurityEventForm.new(body: jwt) } let(:user) { create(:user) } - let(:service_provider) { create(:service_provider) } + let(:agency) { Agency.last || Agency.create(name: 'Test Agency') } + let(:service_provider) { create(:service_provider, agency_id: agency.id) } let(:rp_private_key) do OpenSSL::PKey::RSA.new( File.read(Rails.root.join('keys', 'saml_test_sp.key')), @@ -33,7 +34,7 @@ } end - let(:subject_sub) { identity.uuid } + let(:subject_sub) { AgencyIdentityLinker.new(identity).link_identity.uuid } let(:jwt_headers) { { typ: 'secevent+jwt' } } let(:jwt) { JWT.encode(jwt_payload, rp_private_key, 'RS256', jwt_headers) } @@ -272,6 +273,15 @@ expect(form.errors[:sub]).to include('invalid event.subject.sub claim') end end + + context 'when the service provider has no agency' do + let(:service_provider) { create(:service_provider, agency: nil, agency_id: nil) } + + it 'is still valid' do + expect(valid?).to eq(true) + expect(form.error_code).to eq(nil) + end + end end context 'with a top-level sub claim' do diff --git a/spec/javascripts/app/document-capture/components/accordion-spec.jsx b/spec/javascripts/app/document-capture/components/accordion-spec.jsx deleted file mode 100644 index 04d1a361076..00000000000 --- a/spec/javascripts/app/document-capture/components/accordion-spec.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import render from '../../../support/render'; -import Accordion from '../../../../../app/javascript/app/document-capture/components/accordion'; - -describe('document-capture/components/accordion', () => { - it('renders with a unique ID', () => { - const { container } = render( - <> - Content - Content - , - ); - - const contents = container.querySelectorAll('[id^="accordion-content-"]'); - - expect(contents).to.have.lengthOf(2); - expect(contents[0].id).to.be.ok(); - expect(contents[1].id).to.be.ok(); - expect(contents[0].id).not.to.equal(contents[1].id); - }); -}); diff --git a/spec/javascripts/app/document-capture/components/acuant-capture-spec.jsx b/spec/javascripts/app/document-capture/components/acuant-capture-spec.jsx index 6f97c5934a2..49afedf44ae 100644 --- a/spec/javascripts/app/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascripts/app/document-capture/components/acuant-capture-spec.jsx @@ -25,7 +25,18 @@ describe('document-capture/components/acuant-capture', () => { expect(container.textContent).to.equal('Loading…'); }); - it('renders an error indicator if acuant fails to load', () => { + it('renders an error indicator if acuant script fails to load', async () => { + const { findByText } = render( + + + , + ); + + expect(await findByText('Error!')).to.be.ok(); + expect(console).to.have.loggedError(/^Error: Could not load script:/); + }); + + it('renders an error indicator if acuant fails to initialize', () => { const { container } = render( diff --git a/spec/javascripts/app/document-capture/components/documents-step-spec.jsx b/spec/javascripts/app/document-capture/components/documents-step-spec.jsx index c74c5810969..7a54900dacf 100644 --- a/spec/javascripts/app/document-capture/components/documents-step-spec.jsx +++ b/spec/javascripts/app/document-capture/components/documents-step-spec.jsx @@ -2,6 +2,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import sinon from 'sinon'; import render from '../../../support/render'; +import DeviceContext from '../../../../../app/javascript/app/document-capture/context/device'; import DocumentsStep from '../../../../../app/javascript/app/document-capture/components/documents-step'; describe('document-capture/components/documents-step', () => { @@ -39,4 +40,18 @@ describe('document-capture/components/documents-step', () => { // See: https://github.com/testing-library/user-event/issues/421 expect(input.getAttribute('accept')).to.equal('image/*'); }); + + it('renders device-specific instructions', () => { + let { getByText } = render( + + + , + ); + + expect(() => getByText('doc_auth.tips.document_capture_id_text4')).to.throw(); + + getByText = render().getByText; + + expect(() => getByText('doc_auth.tips.document_capture_id_text4')).not.to.throw(); + }); }); diff --git a/spec/javascripts/app/document-capture/components/suspense-error-boundary-spec.jsx b/spec/javascripts/app/document-capture/components/suspense-error-boundary-spec.jsx index 2fd8d68f003..9e49f4fe18e 100644 --- a/spec/javascripts/app/document-capture/components/suspense-error-boundary-spec.jsx +++ b/spec/javascripts/app/document-capture/components/suspense-error-boundary-spec.jsx @@ -1,5 +1,4 @@ import React, { lazy } from 'react'; -import sinon from 'sinon'; import render from '../../../support/render'; import SuspenseErrorBoundary from '../../../../../app/javascript/app/document-capture/components/suspense-error-boundary'; @@ -32,8 +31,6 @@ describe('document-capture/components/suspense-error-boundary', () => { throw new Error(); }; - sinon.stub(console, 'error').callsFake(() => {}); - const { findByText } = render( @@ -41,8 +38,6 @@ describe('document-capture/components/suspense-error-boundary', () => { ); expect(await findByText('Error')).to.be.ok(); - - // eslint-disable-next-line no-console - console.error.restore(); + expect(console).to.have.loggedError(); }); }); diff --git a/spec/javascripts/app/document-capture/context/device-spec.jsx b/spec/javascripts/app/document-capture/context/device-spec.jsx new file mode 100644 index 00000000000..1e392a36866 --- /dev/null +++ b/spec/javascripts/app/document-capture/context/device-spec.jsx @@ -0,0 +1,13 @@ +import React, { useContext } from 'react'; +import render from '../../../support/render'; +import DeviceContext from '../../../../../app/javascript/app/document-capture/context/device'; + +describe('document-capture/context/device', () => { + const ContextValue = () => JSON.stringify(useContext(DeviceContext)); + + it('defaults to an object shape of device supports', () => { + const { container } = render(); + + expect(container.textContent).to.equal('{"isMobile":false}'); + }); +}); diff --git a/spec/javascripts/app/document-capture/hooks/use-async-spec.jsx b/spec/javascripts/app/document-capture/hooks/use-async-spec.jsx index b4540e417ed..93a661792aa 100644 --- a/spec/javascripts/app/document-capture/hooks/use-async-spec.jsx +++ b/spec/javascripts/app/document-capture/hooks/use-async-spec.jsx @@ -1,5 +1,4 @@ import React from 'react'; -import sinon from 'sinon'; import render from '../../../support/render'; import useAsync from '../../../../../app/javascript/app/document-capture/hooks/use-async'; import SuspenseErrorBoundary from '../../../../../app/javascript/app/document-capture/components/suspense-error-boundary'; @@ -52,11 +51,9 @@ describe('document-capture/hooks/use-async', () => { expect(container.textContent).to.equal('Loading'); - sinon.stub(console, 'error').callsFake(() => {}); reject(); expect(await findByText('Error')).to.be.ok(); - // eslint-disable-next-line no-console - console.error.restore(); + expect(console).to.have.loggedError(); }); }); diff --git a/spec/javascripts/app/document-capture/hooks/use-i18n-spec.jsx b/spec/javascripts/app/document-capture/hooks/use-i18n-spec.jsx index 1186ec060a3..f04b66081ec 100644 --- a/spec/javascripts/app/document-capture/hooks/use-i18n-spec.jsx +++ b/spec/javascripts/app/document-capture/hooks/use-i18n-spec.jsx @@ -1,24 +1,72 @@ import React from 'react'; import render from '../../../support/render'; import I18nContext from '../../../../../app/javascript/app/document-capture/context/i18n'; -import useI18n from '../../../../../app/javascript/app/document-capture/hooks/use-i18n'; +import useI18n, { + formatHTML, +} from '../../../../../app/javascript/app/document-capture/hooks/use-i18n'; describe('document-capture/hooks/use-i18n', () => { - const LocalizedString = ({ stringKey }) => useI18n()(stringKey); + describe('formatHTML', () => { + it('returns html string treated as escaped text without handler', () => { + const formatted = formatHTML('Hello world!', {}); - it('returns localized key value', () => { - const { container } = render( - - - , - ); + const { container } = render(formatted); - expect(container.textContent).to.equal('translation'); + expect(container.innerHTML).to.equal('Hello <strong>world</strong>!'); + }); + + it('returns html string chunked by handlers', () => { + const formatted = formatHTML('Hello world!', { + strong: ({ children }) => {children}, + }); + + const { container } = render(formatted); + + expect(container.innerHTML).to.equal('Hello world!'); + }); + + it('returns html string chunked by multiple handlers', () => { + const formatted = formatHTML( + 'Message: Hello world!', + { + 'lg-custom': () => 'Greetings', + strong: ({ children }) => {children}, + }, + ); + + const { container } = render(formatted); + + expect(container.innerHTML).to.equal('Message: Greetings world!'); + }); + + it('removes dangling empty text fragment', () => { + const formatted = formatHTML('Hello world', { + strong: ({ children }) => {children}, + }); + + const { container } = render(formatted); + + expect(container.childNodes).to.have.lengthOf(2); + }); }); - it('falls back to key value', () => { - const { container } = render(); + describe('t', () => { + const LocalizedString = ({ stringKey }) => useI18n().t(stringKey); + + it('returns localized key value', () => { + const { container } = render( + + + , + ); + + expect(container.textContent).to.equal('translation'); + }); + + it('falls back to key value', () => { + const { container } = render(); - expect(container.textContent).to.equal('sample'); + expect(container.textContent).to.equal('sample'); + }); }); }); diff --git a/spec/javascripts/app/webauthn_spec.js b/spec/javascripts/app/webauthn_spec.js index a60096d94be..1393d075fe6 100644 --- a/spec/javascripts/app/webauthn_spec.js +++ b/spec/javascripts/app/webauthn_spec.js @@ -1,23 +1,22 @@ -import atob from 'atob'; -import btoa from 'btoa'; import * as WebAuthn from '../../../app/javascript/app/webauthn'; describe('WebAuthn', () => { + let originalNavigator; + let originalCredentials; beforeEach(() => { - global.window = { - atob, - btoa, - location: { hostname: 'testing.webauthn.js' }, - }; - global.Uint8Array = Buffer; - global.navigator = { - credentials: { - create: () => {}, - get: () => {}, - }, + originalNavigator = global.navigator; + originalCredentials = global.navigator.credentials; + global.navigator.credentials = { + create: () => {}, + get: () => {}, }; }); + afterEach(() => { + global.navigator = originalNavigator; + global.navigator.credentials = originalCredentials; + }); + describe('isWebAuthnEnabled', () => { it('returns true if webauthn is enabled', () => { expect(WebAuthn.isWebAuthnEnabled()).to.equal(true); @@ -41,15 +40,15 @@ describe('WebAuthn', () => { const userId = '123'; const userEmail = 'test@test.com'; const userChallenge = '[1, 2, 3, 4, 5, 6, 7, 8]'; - const excludeCredentials = 'credential123,credential456'; + const excludeCredentials = 'Y3JlZGVudGlhbDEyMw==,Y3JlZGVudGlhbDQ1Ng=='; // Base64-encoded 'credential123,credential456' it('enrolls a device using the proper create options', (done) => { const expectedCreateOptions = { publicKey: { - challenge: Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]), - rp: { name: 'testing.webauthn.js' }, + challenge: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + rp: { name: 'example.test' }, user: { - id: Buffer.from([123, 0, 0, 0, 0, 0, 0, 0]), + id: new Uint8Array([123, 0, 0, 0, 0, 0, 0, 0]), name: 'test@test.com', displayName: 'test@test.com', }, @@ -143,13 +142,13 @@ describe('WebAuthn', () => { describe('verifyWebauthnDevice', () => { const userChallenge = '[1, 2, 3, 4, 5, 6, 7, 8]'; - const credentialIds = 'credential123,credential456'; + const credentialIds = 'Y3JlZGVudGlhbDEyMw==,Y3JlZGVudGlhbDQ1Ng=='; // Base64-encoded 'credential123,credential456' it('enrolls a device using the proper get options', (done) => { const expectedGetOptions = { publicKey: { - challenge: Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]), - rpId: 'testing.webauthn.js', + challenge: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + rpId: 'example.test', allowCredentials: [ { // encodes to 'credential123' diff --git a/spec/javascripts/spec_helper.js b/spec/javascripts/spec_helper.js index 723272a82eb..e2d5fd0cf10 100644 --- a/spec/javascripts/spec_helper.js +++ b/spec/javascripts/spec_helper.js @@ -1,21 +1,20 @@ -const chai = require('chai'); -const dirtyChai = require('dirty-chai'); -const { JSDOM } = require('jsdom'); +import chai from 'chai'; +import dirtyChai from 'dirty-chai'; +import { createDOM, useCleanDOM } from './support/dom'; +import { chaiConsoleSpy, useConsoleLogSpy } from './support/console'; chai.use(dirtyChai); +chai.use(chaiConsoleSpy); global.expect = chai.expect; // Emulate a DOM, since many modules will assume the presence of these globals exist as a side // effect of their import (focus-trap, classList.js, etc). A URL is provided as a prerequisite to // managing history API (pushState, etc). -const dom = new JSDOM('', { url: 'http://example.test' }); +const dom = createDOM(); global.window = dom.window; global.navigator = window.navigator; global.document = window.document; global.self = window; -beforeEach(() => { - while (document.body.firstChild) { - document.body.removeChild(document.body.firstChild); - } -}); +useCleanDOM(); +useConsoleLogSpy(); diff --git a/spec/javascripts/support/console.js b/spec/javascripts/support/console.js new file mode 100644 index 00000000000..cfe8603ab88 --- /dev/null +++ b/spec/javascripts/support/console.js @@ -0,0 +1,61 @@ +/* eslint-disable no-console */ + +import sinon from 'sinon'; + +/** + * Chai plugin which adds chainable `logged` method, to be used in combination with + * `useConsoleLogSpy` test helper to validate expected console logging. + * + * @see https://www.chaijs.com/guide/plugins/ + * @see https://www.chaijs.com/api/plugins/ + * + * @param {import('chai')} chai Chai object. + * @param {import('chai/lib/chai/utils')} utils Chai plugin utilities. + */ +export function chaiConsoleSpy(chai, utils) { + utils.addChainableMethod( + chai.Assertion.prototype, + 'loggedError', + (message) => { + if (message) { + const index = console.unverifiedCalls.findIndex((calledMessage) => + message instanceof RegExp ? message.test(calledMessage) : message === calledMessage, + ); + let error = `Expected console to have logged: ${message}. `; + error += console.unverifiedCalls + ? `Console logged with: ${console.unverifiedCalls.join(', ')}` + : 'Console did not log.'; + + expect(index).to.not.equal(-1, error); + + console.unverifiedCalls.splice(index, 1); + if (console.unverifiedCalls.length === 0) { + delete console.unverifiedCalls; + } + } else { + delete console.unverifiedCalls; + } + }, + undefined, + ); +} + +/** + * Test lifecycle helper which stubs `console.error` and verifies that any logging which occurs to + * this method is validated using the `logged` chainable assertion implemented by the + * `chaiConsoleSpy` Chai plugin. + */ +export function useConsoleLogSpy() { + beforeEach(() => { + sinon.stub(console, 'error').callsFake((message) => { + console.unverifiedCalls = (console.unverifiedCalls || []).concat(message); + }); + }); + + afterEach(() => { + console.error.restore(); + expect(console.unverifiedCalls).to.be.undefined( + `Unexpected console logging: ${(console.unverifiedCalls || []).join(', ')}`, + ); + }); +} diff --git a/spec/javascripts/support/dom.js b/spec/javascripts/support/dom.js new file mode 100644 index 00000000000..aae99158d4b --- /dev/null +++ b/spec/javascripts/support/dom.js @@ -0,0 +1,32 @@ +import { JSDOM, ResourceLoader } from 'jsdom'; + +/** + * Returns an instance of a JSDOM DOM instance configured for the test environment. + * + * @return {import('jsdom').JSDOM} DOM instance. + */ +export function createDOM() { + return new JSDOM('', { + url: 'http://example.test', + resources: new (class extends ResourceLoader { + // eslint-disable-next-line class-methods-use-this + fetch(url) { + return url === 'about:blank' + ? Promise.resolve(Buffer.from('')) + : Promise.reject(new Error('Failed to load')); + } + })(), + runScripts: 'dangerously', + }); +} + +/** + * Test lifecycle helper which ensures a clean DOM document for each test case. + */ +export function useCleanDOM() { + beforeEach(() => { + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + }); +} diff --git a/spec/lib/asset_checker_spec.rb b/spec/lib/asset_checker_spec.rb index 8c9b2f5647f..e82dc0c82ac 100644 --- a/spec/lib/asset_checker_spec.rb +++ b/spec/lib/asset_checker_spec.rb @@ -9,7 +9,7 @@ def get_js_with_strings(asset, translation) import useI18n from '../hooks/use-i18n'; function DocumentCapture() { - const t = useI18n(); + const { t } = useI18n(); const sample = ( {t('#{translation}')}

+ \"\" ); } @@ -73,7 +74,7 @@ def get_js_with_strings(asset, translation) translation_strings) expect(AssetChecker).to receive(:warn).with(tempfile.path) expect(AssetChecker).to receive(:warn).with('Missing translation, not-found') - expect(AssetChecker).to receive(:warn).with('Missing asset, wont_find.svg') + expect(AssetChecker).to receive(:warn).twice.with('Missing asset, wont_find.svg') expect(AssetChecker.check_files([tempfile.path])).to eq(true) end end diff --git a/spec/services/acuant/responses/get_results_response_spec.rb b/spec/services/acuant/responses/get_results_response_spec.rb index 89e1df14e03..bd444395d57 100644 --- a/spec/services/acuant/responses/get_results_response_spec.rb +++ b/spec/services/acuant/responses/get_results_response_spec.rb @@ -15,6 +15,14 @@ expect(response.success?).to eq(true) expect(response.errors).to eq([]) expect(response.exception).to be_nil + expect(response.to_h).to eq( + success: true, + errors: [], + exception: nil, + result: 'Passed', + ) + expect(response.result_code).to eq(Acuant::ResultCodes::PASSED) + expect(response.result_code.billed?).to eq(true) end it 'parsed PII from the doc' do @@ -52,6 +60,8 @@ [I18n.t('friendly_errors.doc_auth.document_type_could_not_be_determined')], ) expect(response.exception).to be_nil + expect(response.result_code).to eq(Acuant::ResultCodes::UNKNOWN) + expect(response.result_code.billed?).to eq(false) end context 'when a friendly error does not exist for the acuant error message' do diff --git a/spec/services/acuant/responses/liveness_response_spec.rb b/spec/services/acuant/responses/liveness_response_spec.rb index 9a82ced4200..2bea2331814 100644 --- a/spec/services/acuant/responses/liveness_response_spec.rb +++ b/spec/services/acuant/responses/liveness_response_spec.rb @@ -17,6 +17,7 @@ success: true, errors: [], exception: nil, + liveness_assessment: 'Live', liveness_score: 99, acuant_error: { message: nil, code: nil }, ) @@ -39,6 +40,7 @@ success: false, errors: [I18n.t('errors.doc_auth.selfie')], exception: nil, + liveness_assessment: nil, liveness_score: nil, acuant_error: { message: 'Face is too small. Move the camera closer to the face and retake the picture.', diff --git a/spec/services/acuant/result_codes_spec.rb b/spec/services/acuant/result_codes_spec.rb new file mode 100644 index 00000000000..9af0fcc207f --- /dev/null +++ b/spec/services/acuant/result_codes_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe Acuant::ResultCodes do + describe '.from_int' do + it 'is a result code for the int' do + result_code = Acuant::ResultCodes.from_int(1) + expect(result_code).to be_a(Acuant::ResultCodes::ResultCode) + expect(result_code.billed?).to eq(true) + end + + it 'is nil when there is no matching code' do + result_code = Acuant::ResultCodes.from_int(999) + expect(result_code).to be_nil + end + end +end diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index 7d0e40c8d6d..e84f25c19cd 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -42,6 +42,10 @@ def idv_doc_auth_document_capture_step idv_doc_auth_step_path(step: :document_capture) end + def idv_doc_auth_mobile_document_capture_step + idv_doc_auth_step_path(step: :mobile_document_capture) + end + def idv_doc_auth_front_image_step idv_doc_auth_step_path(step: :front_image) end @@ -82,23 +86,33 @@ def idv_doc_auth_email_sent_step idv_doc_auth_step_path(step: :email_sent) end - def complete_doc_auth_steps_before_welcome_step + def complete_doc_auth_steps_before_welcome_step(expect_accessible: false) visit idv_doc_auth_welcome_step unless current_path == idv_doc_auth_welcome_step + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible end - def complete_doc_auth_steps_before_upload_step + def complete_doc_auth_steps_before_upload_step(expect_accessible: false) visit idv_doc_auth_welcome_step unless current_path == idv_doc_auth_welcome_step + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible find('label', text: t('doc_auth.instructions.consent')).click click_on t('doc_auth.buttons.continue') end - def complete_doc_auth_steps_before_document_capture_step - complete_doc_auth_steps_before_upload_step + def complete_doc_auth_steps_before_document_capture_step(expect_accessible: false) + complete_doc_auth_steps_before_upload_step(expect_accessible: expect_accessible) + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible click_on t('doc_auth.info.upload_computer_link') end - def complete_doc_auth_steps_before_front_image_step + def complete_doc_auth_steps_before_mobile_document_capture_step + allow(DeviceDetector).to receive(:new).and_return(mobile_device) complete_doc_auth_steps_before_upload_step + click_on t('doc_auth.buttons.use_phone') + end + + def complete_doc_auth_steps_before_front_image_step(expect_accessible: false) + complete_doc_auth_steps_before_upload_step(expect_accessible: expect_accessible) + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible click_on t('doc_auth.info.upload_computer_link') end @@ -113,14 +127,16 @@ def mobile_device AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1') end - def complete_doc_auth_steps_before_ssn_step - complete_doc_auth_steps_before_back_image_step + def complete_doc_auth_steps_before_ssn_step(expect_accessible: false) + complete_doc_auth_steps_before_back_image_step(expect_accessible: expect_accessible) + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible attach_image click_idv_continue end - def complete_doc_auth_steps_before_back_image_step - complete_doc_auth_steps_before_front_image_step + def complete_doc_auth_steps_before_back_image_step(expect_accessible: false) + complete_doc_auth_steps_before_front_image_step(expect_accessible: expect_accessible) + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible attach_image click_idv_continue end @@ -131,26 +147,31 @@ def complete_doc_auth_steps_before_mobile_back_image_step click_idv_continue end - def complete_doc_auth_steps_before_doc_success_step - complete_doc_auth_steps_before_verify_step + def complete_doc_auth_steps_before_doc_success_step(expect_accessible: false) + complete_doc_auth_steps_before_verify_step(expect_accessible: expect_accessible) + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible click_idv_continue end - def complete_all_doc_auth_steps - complete_doc_auth_steps_before_doc_success_step + def complete_all_doc_auth_steps(expect_accessible: false) + complete_doc_auth_steps_before_doc_success_step(expect_accessible: expect_accessible) + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible click_idv_continue end - def complete_doc_auth_steps_before_address_step + def complete_doc_auth_steps_before_address_step(expect_accessible: false) complete_doc_auth_steps_before_verify_step + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible click_link t('doc_auth.buttons.change_address') end - def complete_doc_auth_steps_before_verify_step - complete_doc_auth_steps_before_ssn_step + def complete_doc_auth_steps_before_verify_step(expect_accessible: false) + complete_doc_auth_steps_before_ssn_step(expect_accessible: expect_accessible) + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible if page.current_path == idv_doc_auth_selfie_step attach_image click_idv_continue + expect(page).to be_accessible.according_to :section508, :"best-practice" if expect_accessible end fill_out_ssn_form_ok click_idv_continue diff --git a/spec/svg_spec.rb b/spec/svg_spec.rb index 556f2695b40..e6aa245ad7d 100644 --- a/spec/svg_spec.rb +++ b/spec/svg_spec.rb @@ -10,7 +10,9 @@ it 'does not contain inline style tags (that render poorly in IE due to CSP)' do doc = Nokogiri::XML(File.read(svg_path)) - expect(doc.css('style')).to be_empty + expect(doc.css('style')).to be_empty.or( + have_attributes(text: match(%r{^\s*/\*\s*lint-ignore\s*\*/})), + ) end end end diff --git a/spec/views/idv/review/new.html.slim_spec.rb b/spec/views/idv/review/new.html.slim_spec.rb index 25b84e5b230..4a5c79bcb3d 100644 --- a/spec/views/idv/review/new.html.slim_spec.rb +++ b/spec/views/idv/review/new.html.slim_spec.rb @@ -38,7 +38,7 @@ end it 'contains an accordion with verified user information' do - accordion_selector = generate_class_selector('accordion') + accordion_selector = generate_class_selector('usa-accordion') expect(rendered).to have_xpath("//#{accordion_selector}") end diff --git a/spec/views/shared/_footer_lite.html.slim_spec.rb b/spec/views/shared/_footer_lite.html.erb_spec.rb similarity index 96% rename from spec/views/shared/_footer_lite.html.slim_spec.rb rename to spec/views/shared/_footer_lite.html.erb_spec.rb index 78196788915..000b5328d69 100644 --- a/spec/views/shared/_footer_lite.html.slim_spec.rb +++ b/spec/views/shared/_footer_lite.html.erb_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -describe 'shared/_footer_lite.html.slim' do +describe 'shared/_footer_lite.html.erb' do context 'user is signed out' do before do controller.request.path_parameters[:controller] = 'users/sessions' diff --git a/yarn.lock b/yarn.lock index c06d05807ed..92717799769 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2232,11 +2232,6 @@ browserslist@^4.0.0, browserslist@^4.6.4, browserslist@^4.8.3, browserslist@^4.9 node-releases "^1.1.52" pkg-up "^3.1.0" -btoa@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/btoa/-/btoa-1.2.1.tgz#01a9909f8b2c93f6bf680ba26131eb30f7fa3d73" - integrity sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g== - buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"