diff --git a/.circleci/config.yml b/.circleci/config.yml index a4b3ae6b064..63c6ac677fb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -299,7 +299,7 @@ jobs: name: 'Smoke tests' command: | bin/smoke_test --remote --no-source-env - # - notify-slack-smoke-test-status + - notify-slack-smoke-test-status - store-smoketest-results smoketest-int: working_directory: ~/identity-idp @@ -318,7 +318,7 @@ jobs: name: 'Smoke tests' command: | bin/smoke_test --remote --no-source-env - # - notify-slack-smoke-test-status + - notify-slack-smoke-test-status - store-smoketest-results smoketest-staging: working_directory: ~/identity-idp @@ -337,7 +337,7 @@ jobs: name: 'Smoke tests' command: | bin/smoke_test --remote --no-source-env - # - notify-slack-smoke-test-status + - notify-slack-smoke-test-status - store-smoketest-results smoketest-prod: working_directory: ~/identity-idp @@ -354,7 +354,7 @@ jobs: name: 'Smoke tests' command: | bin/smoke_test --remote --no-source-env - # - notify-slack-smoke-test-status + - notify-slack-smoke-test-status - store-smoketest-results check-pinpoint-config: executor: ruby_browsers diff --git a/.erb-lint.yml b/.erb-lint.yml index aae60fce722..28149675943 100644 --- a/.erb-lint.yml +++ b/.erb-lint.yml @@ -5,7 +5,13 @@ linters: # There's no special reason for these excludes other than the fact that they have existing # issues that haven't been resolved yet. Fix 'em up! exclude: - - '*/app/views/account_reset/*' + - '*/app/views/account_reset/cancel/show.html.erb' + - '*/app/views/account_reset/confirm_delete_account/show.html.erb' + - '*/app/views/account_reset/delete_account/show.html.erb' + - '*/app/views/account_reset/pending/cancel.html.erb' + - '*/app/views/account_reset/pending/confirm.html.erb' + - '*/app/views/account_reset/pending/show.html.erb' + - '*/app/views/account_reset/request/show.html.erb' - '*/app/views/accounts/_auth_apps.html.erb' - '*/app/views/accounts/_backup_codes.html.erb' - '*/app/views/accounts/_connected_app.html.erb' @@ -16,10 +22,10 @@ linters: - '*/app/views/accounts/_webauthn.html.erb' - '*/app/views/accounts/actions/_manage_personal_key.html.erb' - '*/app/views/accounts/show.html.erb' - - '*/app/views/idv/confirmations/*' - - '*/app/views/idv/capture_doc/*' + - '*/app/views/event_disavowal/new.html.erb' + - '*/app/views/idv/capture_doc/document_capture.html.erb' + - '*/app/views/idv/confirmations/show.html.erb' - '*/app/views/idv/doc_auth/_back.html.erb' - - '*/app/views/idv/doc_auth/_error_messages.html.erb' - '*/app/views/idv/doc_auth/_ssn_init.html.erb' - '*/app/views/idv/doc_auth/_ssn_update.html.erb' - '*/app/views/idv/doc_auth/agreement.html.erb' @@ -31,32 +37,72 @@ linters: - '*/app/views/idv/doc_auth/verify_wait.html.erb' - '*/app/views/idv/phone/new.html.erb' - '*/app/views/idv/shared/_document_capture.html.erb' - - '*/app/views/event_disavowal/*' - - '*/app/views/mfa_confirmation/*' - - '*/app/views/reactivate_account/*' - - '*/app/views/session_timeout/*' - - '*/app/views/shared/*' - - '*/app/views/two_factor_authentication/*' - - '*/app/views/user_mailer/*' - - '*/app/views/users/authorization_confirmation/*' - - '*/app/views/users/backup_code_setup/*' - - '*/app/views/users/delete/*' - - '*/app/views/users/edit_phone/*' - - '*/app/views/users/email_language/*' - - '*/app/views/users/emails/*' - - '*/app/views/users/passwords/*' - - '*/app/views/users/piv_cac_setup/*' - - '*/app/views/users/piv_cac_setup_from_sign_in/*' - - '*/app/views/users/rules_of_use/*' - - '*/app/views/users/service_provider_inactive/*' - - '*/app/views/users/service_provider_revoke/*' + - '*/app/views/mfa_confirmation/new.html.erb' + - '*/app/views/reactivate_account/_modal.html.erb' + - '*/app/views/reactivate_account/index.html.erb' + - '*/app/views/session_timeout/_expire_session.html.erb' + - '*/app/views/session_timeout/_ping.html.erb' + - '*/app/views/shared/_alert.html.erb' + - '*/app/views/shared/_banner.html.erb' + - '*/app/views/shared/_block_link.html.erb' + - '*/app/views/shared/_email_languages.html.erb' + - '*/app/views/shared/_footer_lite.html.erb' + - '*/app/views/shared/_maintenance_window_alert.html.erb' + - '*/app/views/shared/_masked_text.html.erb' + - '*/app/views/shared/_nav_branded.html.erb' + - '*/app/views/shared/_one_time_code_input.html.erb' + - '*/app/views/shared/_personal_key.html.erb' + - '*/app/views/shared/_spinner_button.html.erb' + - '*/app/views/shared/_ssn_field.html.erb' + - '*/app/views/shared/_step_indicator.html.erb' + - '*/app/views/shared/_step_indicator_step.html.erb' + - '*/app/views/shared/_validation_message.html.erb' + - '*/app/views/two_factor_authentication/backup_code_verification/show.html.erb' + - '*/app/views/two_factor_authentication/options/index.html.erb' + - '*/app/views/two_factor_authentication/otp_verification/show.html.erb' + - '*/app/views/two_factor_authentication/personal_key_verification/show.html.erb' + - '*/app/views/two_factor_authentication/totp_verification/show.html.erb' + - '*/app/views/two_factor_authentication/webauthn_verification/show.html.erb' + - '*/app/views/user_mailer/account_does_not_exist.html.erb' + - '*/app/views/user_mailer/account_reset_granted.html.erb' + - '*/app/views/user_mailer/account_verified.html.erb' + - '*/app/views/user_mailer/add_email.html.erb' + - '*/app/views/user_mailer/add_email_associated_with_another_account.html.erb' + - '*/app/views/user_mailer/email_added.html.erb' + - '*/app/views/user_mailer/email_confirmation_instructions.html.erb' + - '*/app/views/user_mailer/email_deleted.html.erb' + - '*/app/views/user_mailer/new_device_sign_in.html.erb' + - '*/app/views/user_mailer/password_changed.html.erb' + - '*/app/views/user_mailer/personal_key_regenerated.html.erb' + - '*/app/views/user_mailer/personal_key_sign_in.html.erb' + - '*/app/views/user_mailer/phone_added.html.erb' + - '*/app/views/user_mailer/please_reset_password.html.erb' + - '*/app/views/user_mailer/reset_password_instructions.html.erb' + - '*/app/views/user_mailer/signup_with_your_email.html.erb' + - '*/app/views/user_mailer/unconfirmed_email_instructions.html.erb' + - '*/app/views/users/authorization_confirmation/show.html.erb' + - '*/app/views/users/backup_code_setup/confirm_delete.html.erb' + - '*/app/views/users/backup_code_setup/create.html.erb' + - '*/app/views/users/backup_code_setup/edit.html.erb' + - '*/app/views/users/backup_code_setup/index.html.erb' + - '*/app/views/users/delete/show.html.erb' + - '*/app/views/users/edit_phone/edit.html.erb' + - '*/app/views/users/email_language/show.html.erb' + - '*/app/views/users/emails/show.html.erb' + - '*/app/views/users/emails/verify.html.erb' + - '*/app/views/users/passwords/edit.html.erb' + - '*/app/views/users/piv_cac_setup_from_sign_in/prompt.html.erb' + - '*/app/views/users/piv_cac_setup_from_sign_in/success.html.erb' + - '*/app/views/users/rules_of_use/new.html.erb' + - '*/app/views/users/service_provider_inactive/index.html.erb' + - '*/app/views/users/service_provider_revoke/show.html.erb' - '*/app/views/users/shared/_otp_delivery_preference_selection.html.erb' - '*/app/views/users/shared/_otp_make_default_number.html.erb' - - '*/app/views/users/totp_setup/*' - - '*/app/views/users/two_factor_authentication_setup/*' - - '*/app/views/users/verify_password/*' - - '*/app/views/users/verify_personal_key/*' - - '*/app/views/users/webauthn_setup/*' + - '*/app/views/users/totp_setup/new.html.erb' + - '*/app/views/users/verify_password/new.html.erb' + - '*/app/views/users/verify_personal_key/new.html.erb' + - '*/app/views/users/webauthn_setup/delete.html.erb' + - '*/app/views/users/webauthn_setup/new.html.erb' rubocop_config: inherit_from: - .rubocop.yml diff --git a/Gemfile.lock b/Gemfile.lock index 92182433e4e..02cf956fc65 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -180,7 +180,7 @@ GEM blueprinter (0.25.3) bootsnap (1.9.1) msgpack (~> 1.0) - brakeman (5.1.1) + brakeman (5.1.2) browser (5.3.1) builder (3.2.4) bullet (6.1.5) diff --git a/Makefile b/Makefile index ff7a5b1cc8c..fede08f598a 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ lint: @echo "--- rubocop ---" bundle exec rubocop --parallel @echo "--- brakeman ---" - bundle exec brakeman --skip-files app/services/doc_auth_router.rb + bundle exec brakeman @echo "--- zeitwerk check ---" bin/rails zeitwerk:check @echo "--- bundler-audit ---" diff --git a/Procfile b/Procfile index e8c1f355d93..f5b4d1f46b9 100644 --- a/Procfile +++ b/Procfile @@ -1,5 +1,4 @@ web: bundle exec rackup config.ru --port ${PORT:-3000} --host ${HOST:-localhost} worker: bundle exec good_job start -job_runs: bundle exec rake job_runs:run mailcatcher: mailcatcher -f webpacker: ./bin/webpack-dev-server diff --git a/app/assets/images/idv/capture-tips-clean.svg b/app/assets/images/idv/capture-tips-clean.svg new file mode 100644 index 00000000000..60b0eccdbf9 --- /dev/null +++ b/app/assets/images/idv/capture-tips-clean.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/idv/capture-tips-lighting.svg b/app/assets/images/idv/capture-tips-lighting.svg new file mode 100644 index 00000000000..aba228f49c2 --- /dev/null +++ b/app/assets/images/idv/capture-tips-lighting.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/idv/capture-tips-surface.svg b/app/assets/images/idv/capture-tips-surface.svg new file mode 100644 index 00000000000..e752997bb88 --- /dev/null +++ b/app/assets/images/idv/capture-tips-surface.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/components/_form.scss b/app/assets/stylesheets/components/_form.scss index 077e2d376df..2745e8a9b48 100644 --- a/app/assets/stylesheets/components/_form.scss +++ b/app/assets/stylesheets/components/_form.scss @@ -105,14 +105,20 @@ input::-webkit-inner-spin-button { } } +// Remove/refactor once LGDS v6.3 is released + +.usa-error-message, .usa-success-message { @include u-padding-y(.5); - background-image: url(image-path('alert/success.svg')); - background-position: 0 center; + background-position: 0 .5em; background-repeat: no-repeat; - background-size: 1rem; - color: color('success'); - display: block; + background-size: units($icon-size); font-weight: font-weight('bold'); padding-left: 1.5rem; } + +.usa-success-message { + background-image: url(image-path('alert/success.svg')); + color: color('success'); + display: block; +} diff --git a/app/components/alert_component.html.erb b/app/components/alert_component.html.erb new file mode 100644 index 00000000000..bdbffddffb8 --- /dev/null +++ b/app/components/alert_component.html.erb @@ -0,0 +1,5 @@ +<%= tag.div(**tag_options, class: css_class, role: role) do %> +
+ <%= content_tag(text_tag, content, class: 'usa-alert__text') %> +
+<% end %> diff --git a/app/components/alert_component.rb b/app/components/alert_component.rb new file mode 100644 index 00000000000..8cc5647531c --- /dev/null +++ b/app/components/alert_component.rb @@ -0,0 +1,47 @@ +class AlertComponent < BaseComponent + VALID_TYPES = %i[info success warning error emergency other].freeze + + attr_reader :type, :message, :tag_options, :text_tag + + def initialize(type: :info, text_tag: 'p', message: nil, **tag_options) + if !VALID_TYPES.include?(type) + raise ArgumentError, "`type` #{type} is invalid, expected one of #{VALID_TYPES}" + end + + @type = type + @message = message + @tag_options = tag_options + @text_tag = text_tag + end + + def role + if type == :error + 'alert' + else + 'status' + end + end + + def css_class + ['usa-alert', modifier_css_class, *tag_options[:class]] + end + + def modifier_css_class + case type + when :info + 'usa-alert--info' + when :success + 'usa-alert--success' + when :error + 'usa-alert--error' + when :warning + 'usa-alert--warning' + when :emergency + 'usa-alert--emergency' + end + end + + def content + @message || super + end +end diff --git a/app/components/flash_component.html.erb b/app/components/flash_component.html.erb new file mode 100644 index 00000000000..a174b5e89b1 --- /dev/null +++ b/app/components/flash_component.html.erb @@ -0,0 +1,3 @@ +<% alerts.each do |type, message| %> + <%= render AlertComponent.new(type: type, message: message.html_safe, class: 'margin-bottom-4') %> +<% end %> diff --git a/app/components/flash_component.rb b/app/components/flash_component.rb new file mode 100644 index 00000000000..90f465741e4 --- /dev/null +++ b/app/components/flash_component.rb @@ -0,0 +1,28 @@ +class FlashComponent < BaseComponent + VALID_FLASH_TYPES = %w[error info success warning other notice alert].freeze + + attr_reader :flash + + def initialize(flash:) + @flash = flash + end + + def alerts + flash. + to_hash. + slice(*VALID_FLASH_TYPES). + select { |_flash_type, message| message.present? }. + map { |flash_type, message| [alert_type(flash_type), message] } + end + + def alert_type(flash_type) + case flash_type + when 'notice' + :info + when 'alert' + :error + else + flash_type.to_sym + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f9075929691..369d3aa63a5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,9 +7,6 @@ class ApplicationController < ActionController::Base include VerifySpAttributesConcern include EffectiveUser - FLASH_KEYS = %w[error info success warning other].freeze - FLASH_KEY_MAP = { 'notice' => 'info', 'alert' => 'error' }.freeze - # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. protect_from_forgery with: :exception diff --git a/app/controllers/health/health_controller.rb b/app/controllers/health/health_controller.rb index fb653dbafc7..99927729aca 100644 --- a/app/controllers/health/health_controller.rb +++ b/app/controllers/health/health_controller.rb @@ -1,5 +1,3 @@ -require 'job_runner/runner' - module Health class HealthController < AbstractHealthController private @@ -9,14 +7,7 @@ def health_checker database: DatabaseHealthChecker, account_reset: AccountResetHealthChecker, } - if job_run_healthchecks_enabled? - checkers[:job_runner_critical] = JobRunner::HealthCheckerCritical - end MultiHealthChecker.new(**checkers) end - - def job_run_healthchecks_enabled? - IdentityConfig.store.job_run_healthchecks_enabled - end end end diff --git a/app/controllers/health/jobs_controller.rb b/app/controllers/health/jobs_controller.rb deleted file mode 100644 index f601060f130..00000000000 --- a/app/controllers/health/jobs_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Health - class JobsController < AbstractHealthController - private - - def health_checker - JobRunner::HealthChecker - end - end -end diff --git a/app/controllers/idv/doc_auth_controller.rb b/app/controllers/idv/doc_auth_controller.rb index 986054fded4..88b170c1969 100644 --- a/app/controllers/idv/doc_auth_controller.rb +++ b/app/controllers/idv/doc_auth_controller.rb @@ -11,6 +11,9 @@ class DocAuthController < ApplicationController before_action :override_document_capture_step_csp before_action :update_if_skipping_upload + # rubocop:disable Rails/LexicallyScopedActionFilter + before_action :check_for_outage, only: :show + # rubocop:enable Rails/LexicallyScopedActionFilter FSM_SETTINGS = { step_url: :idv_doc_auth_step_url, @@ -54,5 +57,13 @@ def do_meta_refresh(meta_refresh_count) def flow_session user_session['idv/doc_auth'] end + + def check_for_outage + if VendorStatus.new.any_ial2_vendor_outage? + session[:vendor_outage_redirect] = current_step + session[:vendor_outage_redirect_from_idv] = true + redirect_to vendor_outage_url + end + end end end diff --git a/app/controllers/idv/gpo_controller.rb b/app/controllers/idv/gpo_controller.rb index 94bf7bdf992..4ba153f68cd 100644 --- a/app/controllers/idv/gpo_controller.rb +++ b/app/controllers/idv/gpo_controller.rb @@ -145,7 +145,7 @@ def form_response(result, success) def idv_throttle_params { user: idv_session.current_user, - throttle_type: :idv_resolution, + throttle_type: :proof_address, } end @@ -171,7 +171,7 @@ def max_attempts_reached if idv_attempter_throttled? analytics.track_event( Analytics::THROTTLER_RATE_LIMIT_TRIGGERED, - throttle_type: :idv_resolution, + throttle_type: :proof_address, step_name: :gpo, ) flash_error diff --git a/app/controllers/idv/phone_controller.rb b/app/controllers/idv/phone_controller.rb index 2b88aed7e02..e53ad15e7b2 100644 --- a/app/controllers/idv/phone_controller.rb +++ b/app/controllers/idv/phone_controller.rb @@ -8,7 +8,7 @@ class PhoneController < ApplicationController before_action :set_idv_form def new - redirect_to failure_url(:fail) and return if idv_attempter_throttled? + redirect_to failure_url(:fail) and return if throttle.throttled? async_state = step.async_state if async_state.none? @@ -35,10 +35,14 @@ def create private + def throttle + @throttle ||= Throttle.for(user: current_user, throttle_type: :proof_address) + end + def max_attempts_reached analytics.track_event( Analytics::THROTTLER_RATE_LIMIT_TRIGGERED, - throttle_type: :idv_resolution, + throttle_type: :proof_address, step_name: step_name, ) end diff --git a/app/controllers/idv/phone_errors_controller.rb b/app/controllers/idv/phone_errors_controller.rb index bfa8ec84ba2..0551ab57e7e 100644 --- a/app/controllers/idv/phone_errors_controller.rb +++ b/app/controllers/idv/phone_errors_controller.rb @@ -24,7 +24,7 @@ def failure private def throttle - Throttle.for(user: idv_session.current_user, throttle_type: :idv_resolution) + Throttle.for(user: idv_session.current_user, throttle_type: :proof_address) end def confirm_idv_phone_step_needed diff --git a/app/controllers/sign_up/registrations_controller.rb b/app/controllers/sign_up/registrations_controller.rb index 4f11e225b92..b8eff0e2b50 100644 --- a/app/controllers/sign_up/registrations_controller.rb +++ b/app/controllers/sign_up/registrations_controller.rb @@ -1,9 +1,13 @@ module SignUp class RegistrationsController < ApplicationController include PhoneConfirmation + include ApplicationHelper # for ial2_requested? before_action :confirm_two_factor_authenticated, only: [:destroy_confirm] before_action :require_no_authentication + before_action :redirect_if_ial2_and_vendor_outage + + CREATE_ACCOUNT = 'create_account' def new @register_user_email_form = RegisterUserEmailForm.new(analytics: analytics) @@ -62,5 +66,12 @@ def sp_request_id ServiceProviderRequestProxy.from_uuid(request_id).uuid end + + def redirect_if_ial2_and_vendor_outage + return unless ial2_requested? && VendorStatus.new.any_ial2_vendor_outage? + + session[:vendor_outage_redirect] = CREATE_ACCOUNT + return redirect_to vendor_outage_url + end end end diff --git a/app/controllers/vendor_outage_controller.rb b/app/controllers/vendor_outage_controller.rb new file mode 100644 index 00000000000..55f20ff42ea --- /dev/null +++ b/app/controllers/vendor_outage_controller.rb @@ -0,0 +1,11 @@ +class VendorOutageController < ApplicationController + def show + vendor_status = VendorStatus.new( + sp: current_sp, + from: session.delete(:vendor_outage_redirect), + from_idv: session.delete(:vendor_outage_redirect_from_idv), + ) + @specific_message = vendor_status.outage_message + vendor_status.track_event(analytics) + end +end diff --git a/app/javascript/packages/document-capture/components/block-link.jsx b/app/javascript/packages/components/block-link.jsx similarity index 100% rename from app/javascript/packages/document-capture/components/block-link.jsx rename to app/javascript/packages/components/block-link.jsx diff --git a/spec/javascripts/packages/document-capture/components/block-link-spec.jsx b/app/javascript/packages/components/block-link.spec.jsx similarity index 85% rename from spec/javascripts/packages/document-capture/components/block-link-spec.jsx rename to app/javascript/packages/components/block-link.spec.jsx index 5d1821ba03b..b37dcb0617a 100644 --- a/spec/javascripts/packages/document-capture/components/block-link-spec.jsx +++ b/app/javascript/packages/components/block-link.spec.jsx @@ -1,7 +1,7 @@ import { render } from '@testing-library/react'; -import BlockLink from '@18f/identity-document-capture/components/block-link'; +import BlockLink from './block-link'; -describe('document-capture/components/block-link', () => { +describe('BlockLink', () => { const linkText = 'link text'; const url = '/example'; diff --git a/app/javascript/packages/components/index.js b/app/javascript/packages/components/index.js index f3b5d26a59d..cae3d0acae7 100644 --- a/app/javascript/packages/components/index.js +++ b/app/javascript/packages/components/index.js @@ -1,2 +1,4 @@ export { default as Alert } from './alert'; +export { default as BlockLink } from './block-link'; export { default as Icon } from './icon'; +export { default as TroubleshootingOptions } from './troubleshooting-options'; diff --git a/app/javascript/packages/components/package.json b/app/javascript/packages/components/package.json index c3d276925b1..400ac567672 100644 --- a/app/javascript/packages/components/package.json +++ b/app/javascript/packages/components/package.json @@ -3,6 +3,6 @@ "private": true, "version": "1.0.0", "dependencies": { - "react": "^17.0.1" + "react": "^17.0.2" } } diff --git a/app/javascript/packages/components/troubleshooting-options.jsx b/app/javascript/packages/components/troubleshooting-options.jsx new file mode 100644 index 00000000000..f6ff90c53b1 --- /dev/null +++ b/app/javascript/packages/components/troubleshooting-options.jsx @@ -0,0 +1,38 @@ +import { BlockLink } from '@18f/identity-components'; + +/** + * @typedef TroubleshootingOption + * + * @prop {string} url + * @prop {string|JSX.Element} text + * @prop {boolean=} isExternal + */ + +/** + * @typedef TroubleshootingOptionsProps + * + * @prop {string} heading + * @prop {TroubleshootingOption[]} options + */ + +/** + * @param {TroubleshootingOptionsProps} props + */ +function TroubleshootingOptions({ heading, options }) { + return ( +
+

{heading}

+ +
+ ); +} + +export default TroubleshootingOptions; diff --git a/app/javascript/packages/components/troubleshooting-options.spec.jsx b/app/javascript/packages/components/troubleshooting-options.spec.jsx new file mode 100644 index 00000000000..adfefbc76b7 --- /dev/null +++ b/app/javascript/packages/components/troubleshooting-options.spec.jsx @@ -0,0 +1,34 @@ +import { render } from '@testing-library/react'; +import TroubleshootingOptions from './troubleshooting-options'; + +describe('TroubleshootingOptions', () => { + it('renders a given heading', () => { + const { getByRole } = render(); + + const heading = getByRole('heading'); + + expect(heading.textContent).to.equal('Need help?'); + }); + + it('renders given options', () => { + const { getAllByRole } = render( + Option 1, url: 'https://example.com/1', isExternal: true }, + { text: 'Option 2', url: 'https://example.com/2' }, + ]} + />, + ); + + const links = /** @type {HTMLAnchorElement[]} */ (getAllByRole('link')); + + expect(links).to.have.lengthOf(2); + expect(links[0].textContent).to.equal('Option 1 links.new_window'); + expect(links[0].href).to.equal('https://example.com/1'); + expect(links[0].target).to.equal('_blank'); + expect(links[1].textContent).to.equal('Option 2'); + expect(links[1].href).to.equal('https://example.com/2'); + expect(links[1].target).to.be.empty(); + }); +}); diff --git a/app/javascript/packages/compose-components/README.md b/app/javascript/packages/compose-components/README.md new file mode 100644 index 00000000000..fec7f76731e --- /dev/null +++ b/app/javascript/packages/compose-components/README.md @@ -0,0 +1,17 @@ +# `@18f/identity-compose-components` + +A utility function to compose a set of React components and their props to a single component. + +Convenient for flattening a deeply-nested arrangement of context providers, for example. + +## Example + +```jsx +const App = composeComponents( + [FirstContext.Provider, { value: 1 }], + [SecondContext.Provider, { value: 2 }], + AppRoot, +); + +render(App, document.getElementById('app-root')); +``` diff --git a/app/javascript/packages/compose-components/index.js b/app/javascript/packages/compose-components/index.js new file mode 100644 index 00000000000..d152e389d7f --- /dev/null +++ b/app/javascript/packages/compose-components/index.js @@ -0,0 +1,51 @@ +import { createElement } from 'react'; + +/** @typedef {import('react').ComponentType

} ComponentType @template P */ + +/** + * @typedef {[ComponentType

, P]} NormalizedComponentPair + * + * @template P + */ + +/** + * @typedef {[ComponentType

, P]|[ComponentType

]|ComponentType

} ComponentPair + * + * @template P + */ + +/** + * A utility function to compose a set of React components and their props to a single component. + * + * Convenient for flattening a deeply-nested arrangement of context providers, for example. + * + * @example + * ```jsx + * const App = composeComponents( + * [FirstContext.Provider, { value: 1 }], + * [SecondContext.Provider, { value: 2 }], + * AppRoot, + * ); + * + * render(App, document.getElementById('app-root')); + * ``` + * + * @param {...ComponentPair<*>} components + * + * @return {ComponentType<*>} + */ +export function composeComponents(...components) { + return function ComposedComponent() { + /** @type {JSX.Element?} */ + let element = null; + for (let i = components.length - 1; i >= 0; i--) { + const componentPair = /** @type {NormalizedComponentPair<*>} */ (Array.isArray(components[i]) + ? components[i] + : [components[i]]); + const [ComponentType, props] = componentPair; + element = createElement(ComponentType, props, element); + } + + return element; + }; +} diff --git a/app/javascript/packages/compose-components/index.spec.jsx b/app/javascript/packages/compose-components/index.spec.jsx new file mode 100644 index 00000000000..7e1e296e7c0 --- /dev/null +++ b/app/javascript/packages/compose-components/index.spec.jsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react'; +import { render } from '@testing-library/react'; +import { composeComponents } from './index.js'; + +describe('composeComponents', () => { + it('composes components', () => { + const FirstContext = createContext(null); + const SecondContext = createContext(null); + const AppRoot = () => ( + <> + {useContext(FirstContext)} + {useContext(SecondContext)} + + ); + + const ComposedComponent = composeComponents( + [FirstContext.Provider, { value: 1 }], + [SecondContext.Provider, { value: 2 }], + [({ children }) => <>{children}3], + AppRoot, + ); + + const { getByText } = render(); + + expect(getByText('123')).to.be.ok(); + }); +}); diff --git a/app/javascript/packages/compose-components/package.json b/app/javascript/packages/compose-components/package.json new file mode 100644 index 00000000000..8ec82d11652 --- /dev/null +++ b/app/javascript/packages/compose-components/package.json @@ -0,0 +1,8 @@ +{ + "name": "@18f/identity-compose-components", + "private": true, + "version": "1.0.0", + "dependencies": { + "react": "^17.0.2" + } +} diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index 54dddca2561..08561a65d8a 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -10,6 +10,7 @@ import { import { useI18n } from '@18f/identity-react-i18n'; import AnalyticsContext from '../context/analytics'; import AcuantContext from '../context/acuant'; +import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; import AcuantCaptureCanvas from './acuant-capture-canvas'; import FileInput from './file-input'; import FullScreen from './full-screen'; @@ -102,6 +103,11 @@ import './acuant-capture.scss'; * @prop {string} name Prefix to prepend to user action analytics labels. */ +/** + * A noop function. + */ +const noop = () => {}; + /** * Returns true if the given Acuant capture failure was caused by the user declining access to the * camera, or false otherwise. @@ -267,6 +273,9 @@ function AcuantCapture( const { isMobile } = useContext(DeviceContext); const { t, formatHTML } = useI18n(); const [attempt, incrementAttempt] = useCounter(1); + const { onFailedCaptureAttempt, onResetFailedCaptureAttempts } = useContext( + FailedCaptureAttemptsContext, + ); const hasCapture = !isError && (isReady ? isCameraSupported : isMobile); useEffect(() => { // If capture had started before Acuant was ready, stop capture if readiness reveals that no @@ -292,7 +301,7 @@ function AcuantCapture( /** * Returns an analytics payload, decorated with common values. * - * @template P + * @template {ImageAnalyticsPayload|AcuantImageAnalyticsPayload} P * * @param {P} payload * @@ -339,15 +348,16 @@ function AcuantCapture( * @template {(...args: any[]) => any} T * * @param {string} source Click source. + * @param {{isDrop: boolean}=} metadata Additional payload metadata to log. * * @return {(fn: T) => (...args: Parameters) => ReturnType} */ - function withLoggedClick(source) { + function withLoggedClick(source, metadata = { isDrop: false }) { return (fn) => (...args) => { if (!isSuppressingClickLogging.current) { addPageAction({ label: `IdV: ${name} image clicked`, - payload: { source }, + payload: { source, ...metadata }, }); } @@ -474,6 +484,9 @@ function AcuantCapture( if (assessment === 'success') { onChangeAndResetError(data, analyticsPayload); + onResetFailedCaptureAttempts(); + } else { + onFailedCaptureAttempt({ isAssessedAsGlare, isAssessedAsBlurry }); } setIsCapturingEnvironment(false); @@ -521,6 +534,7 @@ function AcuantCapture( value={value} errorMessage={ownErrorMessage ?? errorMessage} onClick={withLoggedClick('placeholder')(startCaptureOrTriggerUpload)} + onDrop={withLoggedClick('placeholder', { isDrop: true })(noop)} onChange={onUpload} onError={() => setOwnErrorMessage(null)} /> diff --git a/app/javascript/packages/document-capture/components/button-to.jsx b/app/javascript/packages/document-capture/components/button-to.jsx new file mode 100644 index 00000000000..3f4aab0f12f --- /dev/null +++ b/app/javascript/packages/document-capture/components/button-to.jsx @@ -0,0 +1,44 @@ +import { useContext, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import UploadContext from '../context/upload'; +import Button from './button'; + +/** @typedef {import('./button').ButtonProps} ButtonProps */ + +/** + * @typedef NativeButtonToProps + * + * @prop {string} url URL to which the user should navigate. + * @prop {string} method Form method button should submit as. + */ + +/** + * @typedef {NativeButtonToProps & ButtonProps} ButtonToProps + */ + +/** + * Component which renders a button that navigates to the specified URL via form, with method + * parameterized as a hidden input and including authenticity token. The form is rendered to the + * document root, to avoid conflicts with nested forms. + * + * @param {ButtonToProps} props + */ +function ButtonTo({ url, method, children, ...buttonProps }) { + const { csrf } = useContext(UploadContext); + const formRef = useRef(/** @type {HTMLFormElement?} */ (null)); + + return ( + + ); +} + +export default ButtonTo; diff --git a/app/javascript/packages/document-capture/components/callback-on-mount.jsx b/app/javascript/packages/document-capture/components/callback-on-mount.jsx index fc34ad151aa..c85984e3b57 100644 --- a/app/javascript/packages/document-capture/components/callback-on-mount.jsx +++ b/app/javascript/packages/document-capture/components/callback-on-mount.jsx @@ -1,20 +1,25 @@ import { useEffect } from 'react'; +/** @typedef {import('react').ReactNode} ReactNode */ + /** * @typedef CallbackOnMountProps * * @prop {()=>void} onMount Callback to trigger on mount. + * @prop {JSX.Element?=} children Element children. */ /** * @param {CallbackOnMountProps} props Props object. + * + * @return {JSX.Element?} */ -function CallbackOnMount({ onMount }) { +function CallbackOnMount({ onMount, children = null }) { useEffect(() => { onMount(); }, []); - return null; + return children; } export default CallbackOnMount; diff --git a/app/javascript/packages/document-capture/components/capture-advice.jsx b/app/javascript/packages/document-capture/components/capture-advice.jsx new file mode 100644 index 00000000000..94791815724 --- /dev/null +++ b/app/javascript/packages/document-capture/components/capture-advice.jsx @@ -0,0 +1,89 @@ +import { useContext } from 'react'; +import { useI18n } from '@18f/identity-react-i18n'; +import ServiceProviderContext from '../context/service-provider'; +import MarketingSiteContext from '../context/marketing-site'; +import useAsset from '../hooks/use-asset'; +import Warning from './warning'; + +/** @typedef {import('@18f/identity-components/troubleshooting-options').TroubleshootingOption} TroubleshootingOption */ + +/** + * @typedef CaptureAdviceProps + * + * @prop {() => void} onTryAgain + * @prop {boolean} isAssessedAsGlare + * @prop {boolean} isAssessedAsBlurry + */ + +/** + * @param {CaptureAdviceProps} props + */ +function CaptureAdvice({ onTryAgain, isAssessedAsGlare, isAssessedAsBlurry }) { + const { name: spName, getFailureToProofURL } = useContext(ServiceProviderContext); + const { documentCaptureTipsURL } = useContext(MarketingSiteContext); + const { getAssetPath } = useAsset(); + const { t } = useI18n(); + + return ( + +

+ {isAssessedAsGlare && t('doc_auth.tips.capture_troubleshooting_glare')} + {isAssessedAsBlurry && t('doc_auth.tips.capture_troubleshooting_blurry')}{' '} + {t('doc_auth.tips.capture_troubleshooting_lead')} +

+ + + ); +} + +export default CaptureAdvice; diff --git a/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx new file mode 100644 index 00000000000..ea25445c511 --- /dev/null +++ b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx @@ -0,0 +1,58 @@ +import { useContext, useState } from 'react'; +import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; +import AnalyticsContext from '../context/analytics'; +import CallbackOnMount from './callback-on-mount'; +import CaptureAdvice from './capture-advice'; +import { FormStepsContext } from './form-steps'; +import useDidUpdateEffect from '../hooks/use-did-update-effect'; + +/** @typedef {import('react').ReactNode} ReactNode */ + +/** + * @typedef CaptureTroubleshootingProps + * + * @prop {ReactNode} children + */ + +/** + * @param {CaptureTroubleshootingProps} props + */ +function CaptureTroubleshooting({ children }) { + const { addPageAction } = useContext(AnalyticsContext); + const [didShowTroubleshooting, setDidShowTroubleshooting] = useState(false); + const { failedCaptureAttempts, maxFailedAttemptsBeforeTips, lastAttemptMetadata } = useContext( + FailedCaptureAttemptsContext, + ); + const { onPageTransition } = useContext(FormStepsContext); + useDidUpdateEffect(onPageTransition, [didShowTroubleshooting]); + const { isAssessedAsGlare, isAssessedAsBlurry } = lastAttemptMetadata; + + function onCaptureTipsShown() { + addPageAction({ + label: 'IdV: Capture troubleshooting shown', + payload: lastAttemptMetadata, + }); + + onPageTransition(); + } + + function onCaptureTipsDismissed() { + addPageAction({ label: 'IdV: Capture troubleshooting dismissed' }); + + setDidShowTroubleshooting(true); + } + + return failedCaptureAttempts >= maxFailedAttemptsBeforeTips && !didShowTroubleshooting ? ( + + + + ) : ( + <>{children} + ); +} + +export default CaptureTroubleshooting; diff --git a/app/javascript/packages/document-capture/components/document-capture.jsx b/app/javascript/packages/document-capture/components/document-capture.jsx index 26bdf01672a..58c839fcb2f 100644 --- a/app/javascript/packages/document-capture/components/document-capture.jsx +++ b/app/javascript/packages/document-capture/components/document-capture.jsx @@ -9,7 +9,6 @@ import ReviewIssuesStep, { reviewIssuesStepValidator } from './review-issues-ste import ServiceProviderContext from '../context/service-provider'; import Submission from './submission'; import SubmissionStatus from './submission-status'; -import DesktopDocumentDisclosure from './desktop-document-disclosure'; import { RetrySubmissionError } from './submission-complete'; import { BackgroundEncryptedUploadError } from '../higher-order/with-background-encrypted-upload'; import SuspenseErrorBoundary from './suspense-error-boundary'; @@ -94,23 +93,18 @@ function DocumentCapture({ isAsyncForm = false, onStepChange }) { ? [ { name: 'review', - title: t('doc_auth.headings.review_issues'), form: ReviewIssuesStep, validator: reviewIssuesStepValidator, - footer: DesktopDocumentDisclosure, }, ] : /** @type {FormStep[]} */ ([ { name: 'documents', - title: t('doc_auth.headings.document_capture'), form: DocumentsStep, validator: documentsStepValidator, - footer: DesktopDocumentDisclosure, }, serviceProvider.isLivenessRequired && { name: 'selfie', - title: t('doc_auth.headings.selfie'), form: SelfieStep, validator: selfieStepValidator, }, diff --git a/app/javascript/packages/document-capture/components/documents-step.jsx b/app/javascript/packages/document-capture/components/documents-step.jsx index 2b1f5d635fa..45ddb9817c5 100644 --- a/app/javascript/packages/document-capture/components/documents-step.jsx +++ b/app/javascript/packages/document-capture/components/documents-step.jsx @@ -1,10 +1,15 @@ import { useContext } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; -import BlockLink from './block-link'; +import { BlockLink } from '@18f/identity-components'; +import { FormStepsContinueButton } from './form-steps'; import DocumentSideAcuantCapture from './document-side-acuant-capture'; import DeviceContext from '../context/device'; import ServiceProviderContext from '../context/service-provider'; import withBackgroundEncryptedUpload from '../higher-order/with-background-encrypted-upload'; +import DesktopDocumentDisclosure from './desktop-document-disclosure'; +import CaptureTroubleshooting from './capture-troubleshooting'; +import PageHeading from './page-heading'; +import StartOverOrCancel from './start-over-or-cancel'; /** * @typedef {'front'|'back'} DocumentSide @@ -48,7 +53,8 @@ function DocumentsStep({ const serviceProvider = useContext(ServiceProviderContext); return ( - <> + + {t('doc_auth.headings.document_capture')} {isMobile &&

{t('doc_auth.info.document_capture_intro_acknowledgment')}

}

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

    @@ -75,7 +81,10 @@ function DocumentsStep({ onError={onError} /> ))} - + + + + ); } diff --git a/app/javascript/packages/document-capture/components/file-input.jsx b/app/javascript/packages/document-capture/components/file-input.jsx index 00a13acdd5d..97f7388998a 100644 --- a/app/javascript/packages/document-capture/components/file-input.jsx +++ b/app/javascript/packages/document-capture/components/file-input.jsx @@ -14,6 +14,7 @@ import useInstanceId from '../hooks/use-instance-id'; import usePrevious from '../hooks/use-previous'; /** @typedef {import('react').MouseEvent} ReactMouseEvent */ +/** @typedef {import('react').DragEvent} ReactDragEvent */ /** @typedef {import('react').ChangeEvent} ReactChangeEvent */ /** @typedef {import('react').RefAttributes} ReactRefAttributes */ /** @typedef {import('react').ReactNode} ReactNode */ @@ -31,6 +32,7 @@ import usePrevious from '../hooks/use-previous'; * @prop {Blob|string|null|undefined} value Current value. * @prop {ReactNode=} errorMessage Error to show. * @prop {(event:ReactMouseEvent)=>void=} onClick Input click handler. + * @prop {(event:ReactDragEvent)=>void=} onDrop Input drop handler. * @prop {(nextValue:File?)=>void=} onChange Input change handler. * @prop {(message:ReactNode)=>void=} onError Callback to trigger if upload error occurs. */ @@ -108,7 +110,8 @@ function FileInput(props, ref) { capture, value, errorMessage, - onClick = () => {}, + onClick, + onDrop, onChange = () => {}, onError = () => {}, } = props; @@ -278,6 +281,7 @@ function FileInput(props, ref) { onChange={onChangeIfValid} capture={capture} onClick={onClick} + onDrop={onDrop} accept={accept ? accept.join() : undefined} aria-describedby={hint ? hintId : undefined} /> diff --git a/app/javascript/packages/document-capture/components/form-steps.jsx b/app/javascript/packages/document-capture/components/form-steps.jsx index 40d06dbfba4..b9dbf2c9778 100644 --- a/app/javascript/packages/document-capture/components/form-steps.jsx +++ b/app/javascript/packages/document-capture/components/form-steps.jsx @@ -1,14 +1,14 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, createContext, useContext } from 'react'; import { Alert } from '@18f/identity-components'; import { useI18n } from '@18f/identity-react-i18n'; import Button from './button'; -import PageHeading from './page-heading'; import FormErrorMessage, { RequiredValueMissingError } from './form-error-message'; import PromptOnNavigate from './prompt-on-navigate'; import useHistoryParam from '../hooks/use-history-param'; import useForceRender from '../hooks/use-force-render'; import useDidUpdateEffect from '../hooks/use-did-update-effect'; import useIfStillMounted from '../hooks/use-if-still-mounted'; +import './form-steps.scss'; /** * @typedef FormStepError @@ -53,9 +53,7 @@ import useIfStillMounted from '../hooks/use-if-still-mounted'; * @typedef FormStep * * @prop {string} name Step name, used in history parameter. - * @prop {string} title Step title, shown as heading. * @prop {import('react').FC>>} form Step form component. - * @prop {import('react').FC=} footer Optional step footer component. * @prop {(object)=>boolean=} validator Optional function to validate values for the step */ @@ -78,6 +76,22 @@ import useIfStillMounted from '../hooks/use-if-still-mounted'; * @prop {()=>void=} onStepChange Callback triggered on step change. */ +/** + * @typedef FormStepsContext + * + * @prop {boolean} isLastStep Whether the current step is the last step in the flow. + * @prop {boolean} canContinueToNextStep Whether the user can proceed to the next step. + * @prop {() => void} onPageTransition Callback invoked when content is reset in a page transition. + */ + +export const FormStepsContext = createContext( + /** @type {FormStepsContext} */ ({ + isLastStep: true, + canContinueToNextStep: true, + onPageTransition: () => {}, + }), +); + /** * Returns the index of the step in the array which matches the given name. Returns `-1` if there is * no step found by that name. @@ -120,12 +134,9 @@ function FormSteps({ }) { const [values, setValues] = useState(initialValues); const [activeErrors, setActiveErrors] = useState(initialActiveErrors); - const firstAlertRef = useRef(/** @type {?HTMLElement} */ (null)); const formRef = useRef(/** @type {?HTMLFormElement} */ (null)); - const headingRef = useRef(/** @type {?HTMLHeadingElement} */ (null)); const [stepName, setStepName] = useHistoryParam('step', null); const [stepErrors, setStepErrors] = useState(/** @type {Error[]} */ ([])); - const { t } = useI18n(); const fields = useRef(/** @type {Record} */ ({})); const didSubmitWithErrors = useRef(false); const forceRender = useForceRender(); @@ -141,20 +152,35 @@ function FormSteps({ const stepIndex = Math.max(getStepIndexByName(steps, stepName), 0); const step = steps[stepIndex]; + /** + * After a change in content, maintain focus by resetting to the beginning of the new content. + */ + function onPageTransition() { + const firstElementChild = formRef.current?.firstElementChild; + if (firstElementChild instanceof window.HTMLElement) { + firstElementChild.classList.add('form-steps__focus-anchor'); + firstElementChild.setAttribute('tabindex', '-1'); + firstElementChild.focus(); + } + + setStepName(stepName); + } + useEffect(() => { // Treat explicit initial step the same as step transition, placing focus to header. - if (autoFocus && headingRef.current) { - headingRef.current.focus(); + if (autoFocus) { + onPageTransition(); } }, []); useEffect(() => { - if (stepErrors.length && firstAlertRef.current) { - firstAlertRef.current.focus(); + if (stepErrors.length) { + onPageTransition(); } }, [stepErrors]); useDidUpdateEffect(onStepChange, [step]); + useDidUpdateEffect(onPageTransition, [step]); /** * Returns array of form errors for the current set of values. @@ -183,7 +209,7 @@ function FormSteps({ const isValidStep = step.validator?.(values) ?? true; const hasUnresolvedFieldErrors = activeErrors.length && activeErrors.length > unknownFieldErrors.length; - const canContinue = isValidStep && !hasUnresolvedFieldErrors; + const canContinueToNextStep = isValidStep && !hasUnresolvedFieldErrors; /** * Increments state to the next step, or calls onComplete callback if the current step is the last @@ -218,77 +244,75 @@ function FormSteps({ const { name: nextStepName } = steps[nextStepIndex]; setStepName(nextStepName); } - - headingRef.current?.focus(); } - const { form: Form, footer: Footer, name, title } = step; + const { form: Form, name } = step; const isLastStep = stepIndex + 1 === steps.length; return (
    {Object.keys(values).length > 0 && } - {stepErrors.concat(unknownFieldErrors.map(({ error }) => error)).map((error, i) => ( - + {stepErrors.concat(unknownFieldErrors.map(({ error }) => error)).map((error) => ( + ))} - - {title} - - { - setActiveErrors((prevActiveErrors) => - prevActiveErrors.filter(({ field }) => !(field in nextValuesPatch)), - ); - setValues((prevValues) => ({ ...prevValues, ...nextValuesPatch })); - })} - onError={ifStillMounted((error, { field } = {}) => { - if (field) { - setActiveErrors((prevActiveErrors) => prevActiveErrors.concat({ field, error })); - } else { - setStepErrors([error]); - } - })} - registerField={(field, options = {}) => { - if (!fields.current[field]) { - fields.current[field] = { - refCallback(fieldNode) { - fields.current[field].element = fieldNode; - - if (activeErrors.length) { - forceRender(); - } - }, - element: null, - isRequired: !!options.isRequired, - }; - } - - return fields.current[field].refCallback; - }} - /> - - {Footer &&