diff --git a/Gemfile b/Gemfile
index fe5eb36727d..79060145dee 100644
--- a/Gemfile
+++ b/Gemfile
@@ -98,7 +98,6 @@ group :development, :test do
gem 'i18n-tasks', '~> 1.0'
gem 'knapsack'
gem 'nokogiri', '~> 1.14.0'
- gem 'parallel_tests', '~> 3.8.0'
gem 'pg_query', require: false
gem 'pry-byebug'
gem 'pry-doc'
diff --git a/Gemfile.lock b/Gemfile.lock
index d6f072de731..108a13e8f7d 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -435,8 +435,6 @@ GEM
openssl (> 2.0, < 3.1)
orm_adapter (0.5.0)
parallel (1.22.1)
- parallel_tests (3.8.1)
- parallel
parser (3.2.0.0)
ast (~> 2.4.1)
pg (1.4.5)
@@ -776,7 +774,6 @@ DEPENDENCIES
newrelic_rpm (~> 8.0)
nokogiri (~> 1.14.0)
octokit (>= 4.25.0)
- parallel_tests (~> 3.8.0)
pg
pg_query
phonelib
diff --git a/Makefile b/Makefile
index bce9ab58f6c..6cfceba4535 100644
--- a/Makefile
+++ b/Makefile
@@ -133,8 +133,8 @@ browsers.json: yarn.lock .browserslistrc ## Generates browsers.json browser supp
yarn generate-browsers-json
test: export RAILS_ENV := test
-test: $(CONFIG) ## Runs RSpec and yarn tests in parallel
- bundle exec rake parallel:spec && yarn test
+test: $(CONFIG) ## Runs RSpec and yarn tests
+ bundle exec rspec && yarn test
test_serial: export RAILS_ENV := test
test_serial: $(CONFIG) ## Runs RSpec and yarn tests serially
diff --git a/app/assets/stylesheets/components/_alert.scss b/app/assets/stylesheets/components/_alert.scss
index 89cc10bb464..9fe7191669d 100644
--- a/app/assets/stylesheets/components/_alert.scss
+++ b/app/assets/stylesheets/components/_alert.scss
@@ -23,3 +23,7 @@
transform: translateY(-50%);
}
}
+
+.usa-alert__text > p:last-child {
+ margin-bottom: 0;
+}
diff --git a/app/components/vendor_outage_alert_component.rb b/app/components/vendor_outage_alert_component.rb
index dbf7a21d104..822ff103efa 100644
--- a/app/components/vendor_outage_alert_component.rb
+++ b/app/components/vendor_outage_alert_component.rb
@@ -37,6 +37,6 @@ def outages
end
def vendor_status
- @vendor_status ||= VendorStatus.new
+ @vendor_status ||= OutageStatus.new
end
end
diff --git a/app/controllers/concerns/api/csrf_token_concern.rb b/app/controllers/concerns/api/csrf_token_concern.rb
new file mode 100644
index 00000000000..593a24043f2
--- /dev/null
+++ b/app/controllers/concerns/api/csrf_token_concern.rb
@@ -0,0 +1,7 @@
+module Api
+ module CsrfTokenConcern
+ def add_csrf_token_header_to_response
+ response.set_header('X-CSRF-Token', form_authenticity_token)
+ end
+ end
+end
diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb
index 1e43757e7a4..1a873998793 100644
--- a/app/controllers/concerns/idv/document_capture_concern.rb
+++ b/app/controllers/concerns/idv/document_capture_concern.rb
@@ -1,7 +1,9 @@
module Idv
module DocumentCaptureConcern
def override_document_capture_step_csp
- return if params[:step] != 'document_capture'
+ if !IdentityConfig.store.doc_auth_document_capture_controller_enabled
+ return if params[:step] != 'document_capture'
+ end
policy = current_content_security_policy
policy.connect_src(*policy.connect_src, 'us.acas.acuant.net')
diff --git a/app/controllers/concerns/idv/step_utilities_concern.rb b/app/controllers/concerns/idv/step_utilities_concern.rb
index abe4a2c268c..626abf69c62 100644
--- a/app/controllers/concerns/idv/step_utilities_concern.rb
+++ b/app/controllers/concerns/idv/step_utilities_concern.rb
@@ -43,5 +43,15 @@ def irs_reproofing?
service_provider: current_sp,
).present?
end
+
+ def document_capture_session
+ @document_capture_session ||= DocumentCaptureSession.find_by(
+ uuid: flow_session[document_capture_session_uuid_key],
+ )
+ end
+
+ def document_capture_session_uuid_key
+ :document_capture_session_uuid
+ end
end
end
diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb
index 9e1acd7dce2..4f3dd6402a7 100644
--- a/app/controllers/concerns/idv/verify_info_concern.rb
+++ b/app/controllers/concerns/idv/verify_info_concern.rb
@@ -114,7 +114,7 @@ def async_state_done(current_async_state)
move_applicant_to_idv_session
idv_session.mark_verify_info_step_complete!
idv_session.invalidate_steps_after_verify_info!
- redirect_to idv_phone_url
+ redirect_to next_step_url
else
idv_session.invalidate_verify_info_step!
end
@@ -122,6 +122,11 @@ def async_state_done(current_async_state)
analytics.idv_doc_auth_verify_proofing_results(**form_response.to_h)
end
+ def next_step_url
+ return idv_gpo_url if OutageStatus.new.gpo_only?
+ idv_phone_url
+ end
+
def summarize_result_and_throttle_failures(summary_result)
if summary_result.success?
add_proofing_components
diff --git a/app/controllers/concerns/reauthentication_required_concern.rb b/app/controllers/concerns/reauthentication_required_concern.rb
index c8e2845ce9a..5bafdbcf1d8 100644
--- a/app/controllers/concerns/reauthentication_required_concern.rb
+++ b/app/controllers/concerns/reauthentication_required_concern.rb
@@ -1,10 +1,32 @@
module ReauthenticationRequiredConcern
+ include MfaSetupConcern
+
def confirm_recently_authenticated
+ if IdentityConfig.store.reauthentication_for_second_factor_management_enabled
+ confirm_recently_authenticated_2fa
+ else
+ @reauthn = reauthn?
+ return unless user_signed_in?
+ return if recently_authenticated?
+
+ prompt_for_current_password
+ end
+ end
+
+ def confirm_recently_authenticated_2fa
@reauthn = reauthn?
- return unless user_signed_in?
- return if recently_authenticated?
+ return unless user_fully_authenticated?
+ non_remembered_device_authentication = user_session[:auth_method].present? &&
+ user_session[:auth_method] != 'remember_device'
+ return if recently_authenticated? && non_remembered_device_authentication
+ return if in_multi_mfa_selection_flow?
+
+ analytics.user_2fa_reauthentication_required(
+ auth_method: user_session[:auth_method],
+ authenticated_at: user_session[:authn_at],
+ )
- prompt_for_current_password
+ prompt_for_second_factor
end
private
@@ -24,6 +46,13 @@ def prompt_for_current_password
redirect_to user_password_confirm_url
end
+ def prompt_for_second_factor
+ store_location(request.url)
+ user_session[:context] = 'reauthentication'
+
+ redirect_to login_two_factor_options_path(reauthn: true)
+ end
+
def factor_from_controller_name
{
# see LG-5701, translate these
diff --git a/app/controllers/concerns/remember_device_concern.rb b/app/controllers/concerns/remember_device_concern.rb
index db2a2b15193..64b44c144f7 100644
--- a/app/controllers/concerns/remember_device_concern.rb
+++ b/app/controllers/concerns/remember_device_concern.rb
@@ -14,7 +14,11 @@ def save_remember_device_preference
end
def check_remember_device_preference
- return unless UserSessionContext.authentication_or_reauthentication_context?(context)
+ if IdentityConfig.store.reauthentication_for_second_factor_management_enabled
+ return unless UserSessionContext.authentication_context?(context)
+ else
+ return unless UserSessionContext.authentication_or_reauthentication_context?(context)
+ end
return if remember_device_cookie.nil?
return unless remember_device_cookie.valid_for_user?(
user: current_user,
diff --git a/app/controllers/idv/doc_auth_controller.rb b/app/controllers/idv/doc_auth_controller.rb
index 2861a53eb7f..b2e55231a30 100644
--- a/app/controllers/idv/doc_auth_controller.rb
+++ b/app/controllers/idv/doc_auth_controller.rb
@@ -70,11 +70,30 @@ def flow_session
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
+ return if flow_session[:skip_vendor_outage]
+
+ return redirect_for_proofing_vendor_outage if OutageStatus.new.any_idv_vendor_outage?
+ return redirect_for_gpo_only if FeatureManagement.idv_gpo_only?
+ end
+
+ def redirect_for_proofing_vendor_outage
+ session[:vendor_outage_redirect] = current_step
+ session[:vendor_outage_redirect_from_idv] = true
+
+ redirect_to vendor_outage_url
+ end
+
+ def redirect_for_gpo_only
+ return redirect_to vendor_outage_url unless FeatureManagement.gpo_verification_enabled?
+
+ # During a phone outage, skip the hybrid handoff
+ # step and go straight to document upload
+ flow_session[:skip_upload_step] = true unless FeatureManagement.idv_allow_hybrid_flow?
+
+ session[:vendor_outage_redirect] = current_step
+ session[:vendor_outage_redirect_from_idv] = true
+
+ redirect_to idv_mail_only_warning_url
end
end
end
diff --git a/app/controllers/idv/document_capture_controller.rb b/app/controllers/idv/document_capture_controller.rb
index 5a75c89d5d9..9505554cba6 100644
--- a/app/controllers/idv/document_capture_controller.rb
+++ b/app/controllers/idv/document_capture_controller.rb
@@ -3,18 +3,35 @@ class DocumentCaptureController < ApplicationController
include IdvSession
include StepIndicatorConcern
include StepUtilitiesConcern
+ include DocumentCaptureConcern
before_action :render_404_if_document_capture_controller_disabled
before_action :confirm_two_factor_authenticated
+ before_action :confirm_agreement_step_complete
+ before_action :override_document_capture_step_csp
def show
increment_step_counts
analytics.idv_doc_auth_document_capture_visited(**analytics_arguments)
+ Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]).
+ call('document_capture', :view, true)
+
render :show, locals: extra_view_variables
end
+ def update
+ handle_stored_result
+
+ analytics.idv_doc_auth_document_capture_submitted(**analytics_arguments)
+
+ Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]).
+ call('document_capture', :update, true)
+
+ redirect_to idv_ssn_url
+ end
+
def extra_view_variables
url_builder = ImageUploadPresignedUrlGenerator.new
@@ -33,7 +50,6 @@ def extra_view_variables
transaction_id: flow_session[:document_capture_session_uuid],
),
}.merge(
- native_camera_ab_testing_variables,
acuant_sdk_upgrade_a_b_testing_variables,
in_person_cta_variant_testing_variables,
)
@@ -45,6 +61,12 @@ def render_404_if_document_capture_controller_disabled
render_not_found unless IdentityConfig.store.doc_auth_document_capture_controller_enabled
end
+ def confirm_agreement_step_complete
+ return if flow_session['Idv::Steps::AgreementStep']
+
+ redirect_to idv_doc_auth_url
+ end
+
def analytics_arguments
{
flow_path: flow_path,
@@ -65,13 +87,6 @@ def increment_step_counts
current_flow_step_counts['Idv::Steps::DocumentCaptureStep'] += 1
end
- def native_camera_ab_testing_variables
- {
- acuant_sdk_upgrade_ab_test_bucket:
- AbTests::ACUANT_SDK.bucket(flow_session[:document_capture_session_uuid]),
- }
- end
-
def acuant_sdk_upgrade_a_b_testing_variables
bucket = AbTests::ACUANT_SDK.bucket(flow_session[:document_capture_session_uuid])
testing_enabled = IdentityConfig.store.idv_acuant_sdk_upgrade_a_b_testing_enabled
@@ -97,5 +112,79 @@ def in_person_cta_variant_testing_variables
in_person_cta_variant_active: bucket,
}
end
+
+ def handle_stored_result
+ if stored_result&.success?
+ save_proofing_components
+ extract_pii_from_doc(stored_result, store_in_session: !hybrid_flow_mobile?)
+ else
+ extra = { stored_result_present: stored_result.present? }
+ failure(I18n.t('doc_auth.errors.general.network_error'), extra)
+ end
+ end
+
+ def stored_result
+ return @stored_result if defined?(@stored_result)
+ @stored_result = document_capture_session&.load_result
+ end
+
+ def save_proofing_components
+ return unless current_user
+
+ doc_auth_vendor = DocAuthRouter.doc_auth_vendor(
+ discriminator: flow_session[document_capture_session_uuid_key],
+ analytics: analytics,
+ )
+
+ component_attributes = {
+ document_check: doc_auth_vendor,
+ document_type: 'state_id',
+ }
+ ProofingComponent.create_or_find_by(user: current_user).update(component_attributes)
+ end
+
+ def hybrid_flow_mobile?
+ user_id_from_token.present?
+ end
+
+ def user_id_from_token
+ flow_session[:doc_capture_user_id]
+ end
+
+ # copied from doc_auth_base_step.rb
+ # @param [DocAuth::Response,
+ # DocumentCaptureSessionAsyncResult,
+ # DocumentCaptureSessionResult] response
+ def extract_pii_from_doc(response, store_in_session: false)
+ pii_from_doc = response.pii_from_doc.merge(
+ uuid: effective_user.uuid,
+ phone: effective_user.phone_configurations.take&.phone,
+ uuid_prefix: ServiceProvider.find_by(issuer: sp_session[:issuer])&.app_id,
+ )
+
+ flow_session[:had_barcode_read_failure] = response.attention_with_barcode?
+ if store_in_session
+ flow_session[:pii_from_doc] ||= {}
+ flow_session[:pii_from_doc].merge!(pii_from_doc)
+ idv_session.clear_applicant!
+ end
+ track_document_state(pii_from_doc[:state])
+ end
+
+ def track_document_state(state)
+ return unless IdentityConfig.store.state_tracking_enabled && state
+ doc_auth_log = DocAuthLog.find_by(user_id: current_user.id)
+ return unless doc_auth_log
+ doc_auth_log.state = state
+ doc_auth_log.save!
+ end
+
+ # copied from Flow::Failure module
+ def failure(message, extra = nil)
+ flow_session[:error_message] = message
+ form_response_params = { success: false, errors: { message: message } }
+ form_response_params[:extra] = extra unless extra.nil?
+ FormResponse.new(**form_response_params)
+ end
end
end
diff --git a/app/controllers/idv/gpo_only_warning_controller.rb b/app/controllers/idv/gpo_only_warning_controller.rb
new file mode 100644
index 00000000000..8735f8fa13f
--- /dev/null
+++ b/app/controllers/idv/gpo_only_warning_controller.rb
@@ -0,0 +1,17 @@
+module Idv
+ class GpoOnlyWarningController < ApplicationController
+ include IdvSession
+ include StepIndicatorConcern
+
+ before_action :confirm_two_factor_authenticated
+
+ def show
+ user_session['idv/doc_auth'][:skip_vendor_outage] = true
+ render :show, locals: { current_sp:, exit_url: }
+ end
+
+ def exit_url
+ current_sp&.return_to_sp_url || account_path
+ end
+ end
+end
diff --git a/app/controllers/idv/phone_controller.rb b/app/controllers/idv/phone_controller.rb
index b16f92a9126..6afe9035396 100644
--- a/app/controllers/idv/phone_controller.rb
+++ b/app/controllers/idv/phone_controller.rb
@@ -14,9 +14,14 @@ class PhoneController < ApplicationController
def new
analytics.idv_phone_use_different(step: params[:step]) if params[:step]
+ async_state = step.async_state
+
+ # It's possible that create redirected here after a success and left the
+ # throttle maxed out. Check for success before checking throttle.
+ return async_state_done(async_state) if async_state.done?
+
redirect_to failure_url(:fail) and return if throttle.throttled?
- async_state = step.async_state
if async_state.none?
Funnel::DocAuth::RegisterStep.new(current_user.id, current_sp&.issuer).
call(:verify_phone, :view, true)
@@ -29,8 +34,6 @@ def new
analytics.proofing_address_result_missing
flash.now[:error] = I18n.t('idv.failure.timeout')
render :new, locals: { gpo_letter_available: gpo_letter_available }
- elsif async_state.done?
- async_state_done(async_state)
end
end
@@ -57,16 +60,9 @@ def throttle
@throttle ||= Throttle.new(user: current_user, throttle_type: :proof_address)
end
- def max_attempts_reached
- analytics.throttler_rate_limit_triggered(
- throttle_type: :proof_address,
- step_name: step_name,
- )
- end
-
def redirect_to_next_step
if phone_confirmation_required?
- if VendorStatus.new.all_phone_vendor_outage?
+ if OutageStatus.new.all_phone_vendor_outage?
redirect_to vendor_outage_path(from: :idv_phone)
else
send_phone_confirmation_otp_and_handle_result
@@ -113,7 +109,6 @@ def handle_send_phone_confirmation_otp_failure(result)
end
def handle_proofing_failure
- max_attempts_reached if step.failure_reason == :fail
redirect_to failure_url(step.failure_reason)
end
@@ -125,6 +120,7 @@ def step
@step ||= Idv::PhoneStep.new(
idv_session: idv_session,
trace_id: amzn_trace_id,
+ analytics: analytics,
attempts_tracker: irs_attempts_api_tracker,
)
end
@@ -186,7 +182,7 @@ def new_phone_added?
def gpo_letter_available
return @gpo_letter_available if defined?(@gpo_letter_available)
- @gpo_letter_available ||= FeatureManagement.enable_gpo_verification? &&
+ @gpo_letter_available ||= FeatureManagement.gpo_verification_enabled? &&
!Idv::GpoMail.new(current_user).mail_spammed?
end
diff --git a/app/controllers/idv/phone_errors_controller.rb b/app/controllers/idv/phone_errors_controller.rb
index 31cfab5e2ec..fed541d4730 100644
--- a/app/controllers/idv/phone_errors_controller.rb
+++ b/app/controllers/idv/phone_errors_controller.rb
@@ -56,7 +56,7 @@ def track_event(type:)
# rubocop:disable Naming/MemoizedInstanceVariableName
def set_gpo_letter_available
return @gpo_letter_available if defined?(@gpo_letter_available)
- @gpo_letter_available ||= FeatureManagement.enable_gpo_verification? &&
+ @gpo_letter_available ||= FeatureManagement.gpo_verification_enabled? &&
!Idv::GpoMail.new(current_user).mail_spammed?
end
# rubocop:enable Naming/MemoizedInstanceVariableName
diff --git a/app/controllers/sign_up/registrations_controller.rb b/app/controllers/sign_up/registrations_controller.rb
index 19204b5c997..51a9ddb7e71 100644
--- a/app/controllers/sign_up/registrations_controller.rb
+++ b/app/controllers/sign_up/registrations_controller.rb
@@ -72,7 +72,7 @@ def sp_request_id
end
def redirect_if_ial2_and_vendor_outage
- return unless ial2_requested? && VendorStatus.new.any_ial2_vendor_outage?
+ return unless ial2_requested? && OutageStatus.new.any_idv_vendor_outage?
session[:vendor_outage_redirect] = CREATE_ACCOUNT
return redirect_to vendor_outage_url
diff --git a/app/controllers/users/backup_code_setup_controller.rb b/app/controllers/users/backup_code_setup_controller.rb
index d1dc40098e2..191ca00855d 100644
--- a/app/controllers/users/backup_code_setup_controller.rb
+++ b/app/controllers/users/backup_code_setup_controller.rb
@@ -3,6 +3,7 @@ class BackupCodeSetupController < ApplicationController
include MfaSetupConcern
include RememberDeviceConcern
include SecureHeadersConcern
+ include ReauthenticationRequiredConcern
before_action :authenticate_user!
before_action :confirm_user_authenticated_for_2fa_setup
@@ -10,6 +11,9 @@ class BackupCodeSetupController < ApplicationController
before_action :set_backup_code_setup_presenter
before_action :apply_secure_headers_override
before_action :authorize_backup_code_disable, only: [:delete]
+ before_action :confirm_recently_authenticated_2fa, if: -> do
+ IdentityConfig.store.reauthentication_for_second_factor_management_enabled
+ end
helper_method :in_multi_mfa_selection_flow?
diff --git a/app/controllers/users/phones_controller.rb b/app/controllers/users/phones_controller.rb
index 22c3210acd9..ca4f3988773 100644
--- a/app/controllers/users/phones_controller.rb
+++ b/app/controllers/users/phones_controller.rb
@@ -31,7 +31,7 @@ def create
private
def redirect_if_phone_vendor_outage
- return unless VendorStatus.new.all_phone_vendor_outage?
+ return unless OutageStatus.new.all_phone_vendor_outage?
redirect_to vendor_outage_path(from: :users_phones)
end
diff --git a/app/controllers/users/piv_cac_authentication_setup_controller.rb b/app/controllers/users/piv_cac_authentication_setup_controller.rb
index ff366d4490c..dbc37096e9c 100644
--- a/app/controllers/users/piv_cac_authentication_setup_controller.rb
+++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb
@@ -5,12 +5,16 @@ class PivCacAuthenticationSetupController < ApplicationController
include MfaSetupConcern
include RememberDeviceConcern
include SecureHeadersConcern
+ include ReauthenticationRequiredConcern
before_action :authenticate_user!
before_action :confirm_user_authenticated_for_2fa_setup
before_action :authorize_piv_cac_disable, only: :delete
before_action :set_piv_cac_setup_csp_form_action_uris, only: :new
before_action :cap_piv_cac_count, only: %i[new submit_new_piv_cac]
+ before_action :confirm_recently_authenticated_2fa, if: -> do
+ IdentityConfig.store.reauthentication_for_second_factor_management_enabled
+ end
helper_method :in_multi_mfa_selection_flow?
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
index 14d112334ca..f10861b521c 100644
--- a/app/controllers/users/sessions_controller.rb
+++ b/app/controllers/users/sessions_controller.rb
@@ -6,6 +6,7 @@ class SessionsController < Devise::SessionsController
include SecureHeadersConcern
include RememberDeviceConcern
include Ial2ProfileConcern
+ include Api::CsrfTokenConcern
rescue_from ActionController::InvalidAuthenticityToken, with: :redirect_to_signin
@@ -15,6 +16,7 @@ class SessionsController < Devise::SessionsController
before_action :check_user_needs_redirect, only: [:new]
before_action :apply_secure_headers_override, only: [:new, :create]
before_action :clear_session_bad_password_count_if_window_expired, only: [:create]
+ after_action :add_csrf_token_header_to_response, only: [:keepalive]
def new
analytics.sign_in_page_visit(
@@ -51,14 +53,12 @@ def destroy
end
def active
- response.headers['Etag'] = '' # clear etags to prevent caching
session[:pinged_at] = now
Rails.logger.debug(alive?: alive?, expires_at: expires_at)
render json: { live: alive?, timeout: expires_at, remaining: remaining_session_time }
end
def keepalive
- response.headers['Etag'] = '' # clear etags to prevent caching
session[:session_expires_at] = now + Devise.timeout_in if alive?
analytics.session_kept_alive if alive?
diff --git a/app/controllers/users/totp_setup_controller.rb b/app/controllers/users/totp_setup_controller.rb
index 9ef3bc836f4..542b0b0a303 100644
--- a/app/controllers/users/totp_setup_controller.rb
+++ b/app/controllers/users/totp_setup_controller.rb
@@ -4,12 +4,16 @@ class TotpSetupController < ApplicationController
include MfaSetupConcern
include RememberDeviceConcern
include SecureHeadersConcern
+ include ReauthenticationRequiredConcern
before_action :authenticate_user!
before_action :confirm_user_authenticated_for_2fa_setup
before_action :set_totp_setup_presenter
before_action :apply_secure_headers_override
before_action :cap_auth_app_count, only: %i[new confirm]
+ before_action :confirm_recently_authenticated_2fa, if: -> do
+ IdentityConfig.store.reauthentication_for_second_factor_management_enabled
+ end
helper_method :in_multi_mfa_selection_flow?
diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb
index 7b152845a77..b413a5bc881 100644
--- a/app/controllers/users/two_factor_authentication_controller.rb
+++ b/app/controllers/users/two_factor_authentication_controller.rb
@@ -43,7 +43,7 @@ def non_phone_redirect
end
def phone_redirect
- return unless phone_enabled? && !VendorStatus.new.any_phone_vendor_outage?
+ return unless phone_enabled? && !OutageStatus.new.any_phone_vendor_outage?
validate_otp_delivery_preference_and_send_code
true
end
@@ -145,7 +145,7 @@ def redirect_if_blank_phone
end
def redirect_to_vendor_outage_if_phone_only
- return unless VendorStatus.new.all_phone_vendor_outage? &&
+ return unless OutageStatus.new.all_phone_vendor_outage? &&
phone_enabled? &&
!MfaPolicy.new(current_user).multiple_factors_enabled?
redirect_to vendor_outage_path(from: :two_factor_authentication)
diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb
index 2b04aa2dd38..adc3ea3d46d 100644
--- a/app/controllers/users/webauthn_setup_controller.rb
+++ b/app/controllers/users/webauthn_setup_controller.rb
@@ -3,11 +3,15 @@ class WebauthnSetupController < ApplicationController
include MfaSetupConcern
include RememberDeviceConcern
include SecureHeadersConcern
+ include ReauthenticationRequiredConcern
before_action :authenticate_user!
before_action :confirm_user_authenticated_for_2fa_setup
before_action :apply_secure_headers_override
before_action :set_webauthn_setup_presenter
+ before_action :confirm_recently_authenticated_2fa, if: -> do
+ IdentityConfig.store.reauthentication_for_second_factor_management_enabled
+ end
helper_method :in_multi_mfa_selection_flow?
diff --git a/app/controllers/vendor_outage_controller.rb b/app/controllers/vendor_outage_controller.rb
index ab47989c85e..fdc023b203f 100644
--- a/app/controllers/vendor_outage_controller.rb
+++ b/app/controllers/vendor_outage_controller.rb
@@ -1,6 +1,6 @@
class VendorOutageController < ApplicationController
def show
- vendor_status = VendorStatus.new(
+ vendor_status = OutageStatus.new(
sp: current_sp,
from: session.delete(:vendor_outage_redirect),
from_idv: session.delete(:vendor_outage_redirect_from_idv),
@@ -17,7 +17,7 @@ def from_idv_phone?
end
def gpo_letter_available?
- FeatureManagement.enable_gpo_verification? &&
+ FeatureManagement.gpo_verification_enabled? &&
current_user &&
!Idv::GpoMail.new(current_user).mail_spammed?
end
diff --git a/app/forms/idv/in_person/address_form.rb b/app/forms/idv/in_person/address_form.rb
index a9bf9764bdf..eb6ce10935f 100644
--- a/app/forms/idv/in_person/address_form.rb
+++ b/app/forms/idv/in_person/address_form.rb
@@ -8,6 +8,10 @@ class AddressForm
attr_accessor(*ATTRIBUTES)
+ def initialize(capture_secondary_id_enabled:)
+ @capture_secondary_id_enabled = capture_secondary_id_enabled
+ end
+
def self.model_name
ActiveModel::Name.new(self, nil, 'InPersonAddress')
end
@@ -28,6 +32,9 @@ def submit(params)
private
+ attr_reader :capture_secondary_id_enabled
+ alias_method :capture_secondary_id_enabled?, :capture_secondary_id_enabled
+
def consume_params(params)
params.each do |key, value|
raise_invalid_address_parameter_error(key) unless ATTRIBUTES.include?(key.to_sym)
diff --git a/app/forms/new_phone_form.rb b/app/forms/new_phone_form.rb
index af4f76885e0..068310eafea 100644
--- a/app/forms/new_phone_form.rb
+++ b/app/forms/new_phone_form.rb
@@ -46,11 +46,11 @@ def submit(params)
end
def delivery_preference_sms?
- !VendorStatus.new.vendor_outage?(:sms)
+ !OutageStatus.new.vendor_outage?(:sms)
end
def delivery_preference_voice?
- VendorStatus.new.vendor_outage?(:sms) || setup_voice_preference?
+ OutageStatus.new.vendor_outage?(:sms) || setup_voice_preference?
end
# @return [Telephony::PhoneNumberInfo, nil]
diff --git a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts
index e961ba9d37b..634aeb4ebfd 100644
--- a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts
+++ b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts
@@ -1,6 +1,6 @@
import type { SinonStub } from 'sinon';
import userEvent from '@testing-library/user-event';
-import { screen, waitFor } from '@testing-library/dom';
+import { screen, waitFor, fireEvent } from '@testing-library/dom';
import { useSandbox, useDefineProperty } from '@18f/identity-test-helpers';
import '@18f/identity-spinner-button/spinner-button-element';
import { CAPTCHA_EVENT_NAME } from './captcha-submit-button-element';
@@ -35,58 +35,30 @@ describe('CaptchaSubmitButtonElement', () => {
`;
});
- it('submits the form', async () => {
+ it('does not prevent default form submission', async () => {
const button = screen.getByRole('button', { name: 'Submit' });
const form = document.querySelector('form')!;
- sandbox.stub(form, 'submit');
+ let didSubmit = false;
+ form.addEventListener('submit', (event) => {
+ expect(event.defaultPrevented).to.equal(false);
+ event.preventDefault();
+ didSubmit = true;
+ });
await userEvent.click(button);
- await waitFor(() => expect((form.submit as SinonStub).called).to.be.true());
+ await waitFor(() => expect(didSubmit).to.be.true());
});
- context('with form validation errors', () => {
- beforeEach(() => {
- document.body.innerHTML = `
-
- `;
- });
-
- it('does not submit the form and reports validity', async () => {
- const button = screen.getByRole('button', { name: 'Submit' });
- const form = document.querySelector('form')!;
- const input = document.querySelector('input')!;
-
- let didSubmit = false;
- form.addEventListener('submit', (event) => {
- event.preventDefault();
- didSubmit = true;
- });
-
- let didReportInvalid = false;
- input.addEventListener('invalid', () => {
- didReportInvalid = true;
- });
+ it('unbinds form events when disconnected', () => {
+ const submitButton = document.querySelector('lg-captcha-submit-button')!;
+ const form = submitButton.form!;
+ form.removeChild(submitButton);
- await userEvent.click(button);
+ sandbox.spy(submitButton, 'shouldInvokeChallenge');
+ fireEvent.submit(form);
- expect(didSubmit).to.be.false();
- expect(didReportInvalid).to.be.true();
- });
-
- it('stops or otherwise prevents the spinner button from spinning', async () => {
- const button = screen.getByRole('button', { name: 'Submit' });
- await userEvent.click(button);
-
- expect(document.querySelector('.spinner-button--spinner-active')).to.not.exist();
- });
+ expect(submitButton.shouldInvokeChallenge).not.to.have.been.called();
});
context('with configured recaptcha', () => {
@@ -130,7 +102,7 @@ describe('CaptchaSubmitButtonElement', () => {
expect(grecaptcha.execute).to.have.been.calledWith(RECAPTCHA_SITE_KEY, {
action: RECAPTCHA_ACTION_NAME,
});
- expect(Object.fromEntries(new FormData(form))).to.deep.equal({
+ expect(Object.fromEntries(new window.FormData(form))).to.deep.equal({
recaptcha_token: RECAPTCHA_TOKEN_VALUE,
});
});
@@ -145,10 +117,14 @@ describe('CaptchaSubmitButtonElement', () => {
const button = screen.getByRole('button', { name: 'Submit' });
const form = document.querySelector('form')!;
- sandbox.stub(form, 'submit');
+ let didSubmit = false;
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ didSubmit = true;
+ });
await userEvent.click(button);
- await waitFor(() => expect((form.submit as SinonStub).called).to.be.true());
+ await waitFor(() => expect(didSubmit).to.be.true());
expect(grecaptcha.ready).not.to.have.been.called();
});
diff --git a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts
index 3828d484d39..7f5eecde1c1 100644
--- a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts
+++ b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts
@@ -1,8 +1,16 @@
export const CAPTCHA_EVENT_NAME = 'lg:captcha-submit-button:challenge';
class CaptchaSubmitButtonElement extends HTMLElement {
+ form: HTMLFormElement | null;
+
connectedCallback() {
- this.button.addEventListener('click', (event) => this.handleButtonClick(event));
+ this.form = this.closest('form');
+
+ this.form?.addEventListener('submit', this.handleFormSubmit);
+ }
+
+ disconnectedCallback() {
+ this.form?.removeEventListener('submit', this.handleFormSubmit);
}
get button(): HTMLButtonElement {
@@ -13,10 +21,6 @@ class CaptchaSubmitButtonElement extends HTMLElement {
return this.querySelector('[type=hidden]')!;
}
- get form(): HTMLFormElement | null {
- return this.closest('form');
- }
-
get recaptchaSiteKey(): string | null {
return this.getAttribute('recaptcha-site-key');
}
@@ -48,21 +52,12 @@ class CaptchaSubmitButtonElement extends HTMLElement {
return !event.defaultPrevented;
}
- handleButtonClick(event: MouseEvent) {
- event.preventDefault();
-
- if (this.form && !this.form.reportValidity()) {
- // Prevent any associated custom click handling, e.g. spinner button spinning
- event.stopImmediatePropagation();
- return;
- }
-
+ handleFormSubmit = (event: SubmitEvent) => {
if (this.shouldInvokeChallenge()) {
+ event.preventDefault();
this.invokeChallenge();
- } else {
- this.submit();
}
- }
+ };
}
declare global {
diff --git a/app/javascript/packages/request/index.spec.ts b/app/javascript/packages/request/index.spec.ts
index e18e0a5a414..7daf28d5500 100644
--- a/app/javascript/packages/request/index.spec.ts
+++ b/app/javascript/packages/request/index.spec.ts
@@ -1,3 +1,5 @@
+import sinon from 'sinon';
+import type { SinonStub } from 'sinon';
import { useSandbox } from '@18f/identity-test-helpers';
import { request } from '.';
@@ -25,6 +27,7 @@ describe('request', () => {
expect(window.fetch).to.have.been.calledOnce();
});
+
it('works even if the CSRF token is not found on the page', async () => {
sandbox.stub(window, 'fetch').callsFake(() =>
Promise.resolve(
@@ -38,6 +41,7 @@ describe('request', () => {
csrf: () => undefined,
});
});
+
it('does not try to send a csrf when csrf is false', async () => {
sandbox.stub(window, 'fetch').callsFake((url, init = {}) => {
const headers = init.headers as Headers;
@@ -54,6 +58,7 @@ describe('request', () => {
csrf: false,
});
});
+
it('prefers the json prop if both json and body props are provided', async () => {
const preferredData = { prefered: 'data' };
sandbox.stub(window, 'fetch').callsFake((url, init = {}) => {
@@ -71,6 +76,7 @@ describe('request', () => {
body: JSON.stringify({ bad: 'data' }),
});
});
+
it('works with the native body prop', async () => {
const preferredData = { this: 'works' };
sandbox.stub(window, 'fetch').callsFake((url, init = {}) => {
@@ -87,6 +93,7 @@ describe('request', () => {
body: JSON.stringify(preferredData),
});
});
+
it('includes additional headers supplied in options', async () => {
sandbox.stub(window, 'fetch').callsFake((url, init = {}) => {
const headers = init.headers as Headers;
@@ -105,6 +112,7 @@ describe('request', () => {
},
});
});
+
it('skips json serialization when json is a boolean', async () => {
const preferredData = { this: 'works' };
sandbox.stub(window, 'fetch').callsFake((url, init = {}) => {
@@ -122,6 +130,7 @@ describe('request', () => {
body: JSON.stringify(preferredData),
});
});
+
it('converts a POJO to a JSON string with supplied via the json property', async () => {
const preferredData = { this: 'works' };
sandbox.stub(window, 'fetch').callsFake((url, init = {}) => {
@@ -138,4 +147,67 @@ describe('request', () => {
json: preferredData,
});
});
+
+ context('with response including csrf token', () => {
+ beforeEach(() => {
+ sandbox.stub(window, 'fetch').callsFake(() =>
+ Promise.resolve(
+ new Response(JSON.stringify({}), {
+ status: 200,
+ headers: [['X-CSRF-Token', 'new-token']],
+ }),
+ ),
+ );
+ });
+
+ it('does nothing, gracefully', async () => {
+ await request('https://example.com', {});
+ });
+
+ context('with global csrf token', () => {
+ beforeEach(() => {
+ document.head.innerHTML += `
+
+
+ `;
+ });
+
+ it('replaces global csrf token with the response token', async () => {
+ await request('https://example.com', {});
+
+ const metaToken = document.querySelector('meta[name="csrf-token"]')!;
+ expect(metaToken.content).to.equal('new-token');
+ });
+
+ it('uses response token for next request', async () => {
+ await request('https://example.com', {});
+ (window.fetch as SinonStub).resetHistory();
+ await request('https://example.com', {});
+ expect(window.fetch).to.have.been.calledWith(
+ sinon.match.string,
+ sinon.match((init) => init!.headers!.get('x-csrf-token') === 'new-token'),
+ );
+ });
+
+ context('with form csrf token', () => {
+ beforeEach(() => {
+ document.body.innerHTML += `
+
+
+ `;
+ });
+
+ it('replaces form tokens with the response token', async () => {
+ await request('https://example.com', {});
+
+ const inputs = document.querySelectorAll('input');
+ expect(inputs).to.have.lengthOf(2);
+ expect(Array.from(inputs).map((input) => input.value)).to.deep.equal([
+ 'new-token',
+ 'new-token',
+ ]);
+ });
+ });
+ });
+ });
});
diff --git a/app/javascript/packages/request/index.ts b/app/javascript/packages/request/index.ts
index 927a71def66..c7b518152e1 100644
--- a/app/javascript/packages/request/index.ts
+++ b/app/javascript/packages/request/index.ts
@@ -12,10 +12,43 @@ interface RequestOptions extends RequestInit {
csrf?: boolean | CSRFGetter;
}
-const getCSRFToken = () =>
- document.querySelector('meta[name="csrf-token"]')?.content;
+class CSRF {
+ static get token(): string | null {
+ return this.#tokenMetaElement?.content || null;
+ }
+
+ static set token(value: string | null) {
+ if (!value) {
+ return;
+ }
+
+ if (this.#tokenMetaElement) {
+ this.#tokenMetaElement.content = value;
+ }
+
+ this.#paramInputElements.forEach((input) => {
+ input.value = value;
+ });
+ }
+
+ static get param(): string | undefined {
+ return this.#paramMetaElement?.content;
+ }
-export async function request(
+ static get #tokenMetaElement(): HTMLMetaElement | null {
+ return document.querySelector('meta[name="csrf-token"]');
+ }
+
+ static get #paramMetaElement(): HTMLMetaElement | null {
+ return document.querySelector('meta[name="csrf-param"]');
+ }
+
+ static get #paramInputElements(): NodeListOf {
+ return document.querySelectorAll(`input[name="${this.param}"]`);
+ }
+}
+
+export async function request(
url: string,
options: Partial = {},
): Promise {
@@ -24,7 +57,7 @@ export async function request(
headers = new Headers(headers);
if (csrf) {
- const csrfToken = typeof csrf === 'boolean' ? getCSRFToken() : csrf();
+ const csrfToken = typeof csrf === 'boolean' ? CSRF.token : csrf();
if (csrfToken) {
headers.set('X-CSRF-Token', csrfToken);
@@ -41,9 +74,11 @@ export async function request(
}
const response = await window.fetch(url, { ...fetchOptions, headers, body });
- if (response.ok) {
- return json ? response.json() : response.text();
+ CSRF.token = response.headers.get('X-CSRF-Token');
+
+ if (!response.ok) {
+ throw new Error();
}
- throw new Error(await response.json());
+ return json ? response.json() : response.text();
}
diff --git a/app/javascript/packages/spinner-button/spinner-button-element.spec.ts b/app/javascript/packages/spinner-button/spinner-button-element.spec.ts
index ed2678340bc..12e132e0d1b 100644
--- a/app/javascript/packages/spinner-button/spinner-button-element.spec.ts
+++ b/app/javascript/packages/spinner-button/spinner-button-element.spec.ts
@@ -3,7 +3,6 @@ import { getByRole, fireEvent, screen } from '@testing-library/dom';
import type { SinonStub } from 'sinon';
import { useSandbox } from '@18f/identity-test-helpers';
import './spinner-button-element';
-import type { SpinnerButtonElement } from './spinner-button-element';
describe('SpinnerButtonElement', () => {
const sandbox = useSandbox({ useFakeTimers: true });
@@ -14,20 +13,37 @@ describe('SpinnerButtonElement', () => {
interface WrapperOptions {
actionMessage?: string;
-
tagName?: string;
-
spinOnClick?: boolean;
+ inForm?: boolean;
+ isButtonTo?: boolean;
}
- function createWrapper({ actionMessage, tagName = 'a', spinOnClick }: WrapperOptions = {}) {
- document.body.innerHTML = `
+ function createWrapper({
+ actionMessage,
+ tagName = 'a',
+ spinOnClick,
+ inForm,
+ isButtonTo,
+ }: WrapperOptions = {}) {
+ let tag;
+ if (tagName === 'a') {
+ tag = 'Click Me';
+ } else {
+ tag = '';
+ }
+
+ if (isButtonTo) {
+ tag = ``;
+ }
+
+ let html = `
- ${tagName === 'a' ? '
Click Me' : '
'}
+ ${tag}
@@ -44,7 +60,13 @@ describe('SpinnerButtonElement', () => {
}
`;
- return document.body.firstElementChild as SpinnerButtonElement;
+ if (inForm) {
+ html = ``;
+ }
+
+ document.body.innerHTML = html;
+
+ return document.querySelector('lg-spinner-button')!;
}
it('shows spinner on click', async () => {
@@ -56,24 +78,64 @@ describe('SpinnerButtonElement', () => {
expect(wrapper.classList.contains('spinner-button--spinner-active')).to.be.true();
});
- it('disables button without preventing form handlers', async () => {
- const wrapper = createWrapper({ tagName: 'button' });
- let submitted = false;
- const form = document.createElement('form');
- form.action = '#';
- form.addEventListener('submit', (event) => {
- submitted = true;
- event.preventDefault();
+ context('inside form', () => {
+ it('disables button without preventing form handlers', async () => {
+ const wrapper = createWrapper({ tagName: 'button', inForm: true });
+ let didSubmit = false;
+ wrapper.form!.addEventListener('submit', (event) => {
+ didSubmit = true;
+ event.preventDefault();
+ });
+ const button = screen.getByRole('button', { name: 'Click Me' });
+
+ await userEvent.type(button, '{Enter}');
+ clock.tick(0);
+
+ expect(didSubmit).to.be.true();
+ expect(button.hasAttribute('disabled')).to.be.true();
+ });
+
+ it('unbinds events when disconnected', () => {
+ const wrapper = createWrapper({ tagName: 'button', inForm: true });
+ const form = wrapper.form!;
+ form.removeChild(wrapper);
+
+ sandbox.spy(wrapper, 'toggleSpinner');
+ fireEvent.submit(form);
+
+ expect(wrapper.toggleSpinner).not.to.have.been.called();
+ });
+ });
+
+ context('with form inside (button_to)', () => {
+ it('disables button without preventing form handlers', async () => {
+ const wrapper = createWrapper({ tagName: 'button', isButtonTo: true });
+ let didSubmit = false;
+ wrapper.form!.addEventListener('submit', (event) => {
+ didSubmit = true;
+ event.preventDefault();
+ });
+ const button = screen.getByRole('button', { name: 'Click Me' });
+
+ await userEvent.type(button, '{Enter}');
+ clock.tick(0);
+
+ expect(didSubmit).to.be.true();
+ expect(button.hasAttribute('disabled')).to.be.true();
});
- document.body.appendChild(form);
- form.appendChild(wrapper);
+ });
+
+ it('does not show spinner if form is invalid', async () => {
+ const wrapper = createWrapper({ tagName: 'button', inForm: true });
+ const form = wrapper.closest('form')!;
+ const input = document.createElement('input');
+ input.required = true;
+ form.appendChild(input);
const button = screen.getByRole('button', { name: 'Click Me' });
await userEvent.type(button, '{Enter}');
- clock.tick(0);
- expect(submitted).to.be.true();
- expect(button.hasAttribute('disabled')).to.be.true();
+ expect(wrapper.classList.contains('spinner-button--spinner-active')).to.be.false();
});
it('announces action message', async () => {
diff --git a/app/javascript/packages/spinner-button/spinner-button-element.ts b/app/javascript/packages/spinner-button/spinner-button-element.ts
index 903fbd9ce28..902f1a5a3ae 100644
--- a/app/javascript/packages/spinner-button/spinner-button-element.ts
+++ b/app/javascript/packages/spinner-button/spinner-button-element.ts
@@ -12,8 +12,43 @@ const DEFAULT_LONG_WAIT_DURATION_MS = 15000;
export class SpinnerButtonElement extends HTMLElement {
elements: SpinnerButtonElements;
+ form: HTMLFormElement | null;
+
#longWaitTimeout?: number;
+ connectedCallback() {
+ this.form = this.querySelector('form') || this.closest('form');
+
+ this.addEventListener('spinner.start', () => this.toggleSpinner(true));
+ this.addEventListener('spinner.stop', () => this.toggleSpinner(false));
+
+ if (this.spinOnClick) {
+ if (this.form) {
+ this.form.addEventListener('submit', this.showSpinner);
+ } else {
+ this.button.addEventListener('click', this.showSpinner);
+ }
+ }
+ }
+
+ disconnectedCallback() {
+ window.clearTimeout(this.#longWaitTimeout);
+
+ if (this.form) {
+ this.form.removeEventListener('submit', this.showSpinner);
+ } else {
+ this.button.removeEventListener('click', this.showSpinner);
+ }
+ }
+
+ get button(): HTMLElement {
+ return this.querySelector('a,button:not([type]),[type="submit"],[type="button"]')!;
+ }
+
+ get actionMessage(): HTMLElement {
+ return this.querySelector('.spinner-button__action-message')!;
+ }
+
get spinOnClick(): boolean {
return this.getAttribute('spin-on-click') !== 'false';
}
@@ -25,38 +60,19 @@ export class SpinnerButtonElement extends HTMLElement {
return Number(this.getAttribute('long-wait-duration-ms')) || DEFAULT_LONG_WAIT_DURATION_MS;
}
- connectedCallback() {
- this.elements = {
- button: this.querySelector('a,button:not([type]),[type="submit"],[type="button"]')!,
- actionMessage: this.querySelector('.spinner-button__action-message')!,
- };
-
- if (this.spinOnClick) {
- this.elements.button.addEventListener('click', () => this.toggleSpinner(true));
- }
- this.addEventListener('spinner.start', () => this.toggleSpinner(true));
- this.addEventListener('spinner.stop', () => this.toggleSpinner(false));
- }
-
- disconnectedCallback() {
- window.clearTimeout(this.#longWaitTimeout);
- }
+ showSpinner = () => this.toggleSpinner(true);
toggleSpinner(isVisible: boolean) {
- const { button, actionMessage } = this.elements;
this.classList.toggle('spinner-button--spinner-active', isVisible);
- // Avoid setting disabled immediately to allow click event to propagate for form submission.
- setTimeout(() => {
- if (isVisible) {
- button.setAttribute('disabled', '');
- } else {
- button.removeAttribute('disabled');
- }
- }, 0);
+ if (isVisible) {
+ this.button.setAttribute('disabled', '');
+ } else {
+ this.button.removeAttribute('disabled');
+ }
- if (actionMessage) {
- actionMessage.textContent = isVisible ? (actionMessage.dataset.message as string) : '';
+ if (this.actionMessage) {
+ this.actionMessage.textContent = isVisible ? this.actionMessage.dataset.message! : '';
}
window.clearTimeout(this.#longWaitTimeout);
@@ -69,7 +85,7 @@ export class SpinnerButtonElement extends HTMLElement {
}
handleLongWait() {
- this.elements.actionMessage?.classList.remove('usa-sr-only');
+ this.actionMessage?.classList.remove('usa-sr-only');
}
}
diff --git a/app/javascript/packs/form-steps-wait.tsx b/app/javascript/packs/form-steps-wait.tsx
index bff1f7d833e..8aab193c404 100644
--- a/app/javascript/packs/form-steps-wait.tsx
+++ b/app/javascript/packs/form-steps-wait.tsx
@@ -99,7 +99,6 @@ export class FormStepsWait {
bind() {
this.elements.form.addEventListener('submit', (event) => this.handleSubmit(event));
- this.elements.form.addEventListener('invalid', () => this.stopSpinner(), true);
}
/**
diff --git a/app/javascript/packs/session-timeout-ping.ts b/app/javascript/packs/session-timeout-ping.ts
index b94862db3ca..882e2f2b5aa 100644
--- a/app/javascript/packs/session-timeout-ping.ts
+++ b/app/javascript/packs/session-timeout-ping.ts
@@ -1,4 +1,5 @@
import { forceRedirect } from '@18f/identity-url';
+import { request } from '@18f/identity-request';
import type { CountdownElement } from '@18f/identity-countdown/countdown-element';
import type { ModalElement } from '@18f/identity-modal';
@@ -48,17 +49,10 @@ const initialTime = new Date();
const modal = document.querySelector('lg-modal.session-timeout-modal')!;
const keepaliveEl = document.getElementById('session-keepalive-btn');
const countdownEls: NodeListOf = modal.querySelectorAll('lg-countdown');
-const csrfEl: HTMLMetaElement | null = document.querySelector('meta[name="csrf-token"]');
-let csrfToken = '';
-if (csrfEl) {
- csrfToken = csrfEl.content;
-}
-
-function notifyNewRelic(request, error, actionName) {
+function notifyNewRelic(error, actionName) {
(window as LoginGovGlobal).newrelic?.addPageAction('Session Ping Error', {
action_name: actionName,
- request_status: request.status,
time_elapsed_ms: new Date().valueOf() - initialTime.valueOf(),
error: error.message,
});
@@ -100,36 +94,17 @@ function success(data: PingResponse) {
}
function ping() {
- const request = new XMLHttpRequest();
- request.open('GET', '/active', true);
-
- request.onload = function () {
- try {
- success(JSON.parse(request.responseText));
- } catch (error) {
- notifyNewRelic(request, error, 'ping');
- }
- };
+ request('/active')
+ .then(success)
+ .catch((error) => notifyNewRelic(error, 'ping'));
- request.send();
setTimeout(ping, frequency);
}
function keepalive() {
- const request = new XMLHttpRequest();
- request.open('POST', '/sessions/keepalive', true);
- request.setRequestHeader('X-CSRF-Token', csrfToken);
-
- request.onload = function () {
- try {
- success(JSON.parse(request.responseText));
- modal.hide();
- } catch (error) {
- notifyNewRelic(request, error, 'keepalive');
- }
- };
-
- request.send();
+ request('/sessions/keepalive', { method: 'POST' })
+ .then(success)
+ .catch((error) => notifyNewRelic(error, 'keepalive'));
}
keepaliveEl?.addEventListener('click', keepalive, false);
diff --git a/app/presenters/idv/gpo_presenter.rb b/app/presenters/idv/gpo_presenter.rb
index 1ee0cb8ed12..9c7b210bb3e 100644
--- a/app/presenters/idv/gpo_presenter.rb
+++ b/app/presenters/idv/gpo_presenter.rb
@@ -18,6 +18,7 @@ def button
end
def fallback_back_path
+ return idv_verify_info_path if OutageStatus.new.any_phone_vendor_outage?
user_needs_address_otp_verification? ? idv_gpo_verify_path : idv_phone_path
end
@@ -25,6 +26,22 @@ def resend_requested?
current_user.decorate.pending_profile_requires_verification?
end
+ def back_or_cancel_partial
+ if OutageStatus.new.gpo_only?
+ 'idv/doc_auth/cancel'
+ else
+ 'idv/shared/back'
+ end
+ end
+
+ def back_or_cancel_parameters
+ if OutageStatus.new.gpo_only?
+ { step: 'gpo' }
+ else
+ { fallback_path: fallback_back_path }
+ end
+ end
+
private
def user_needs_address_otp_verification?
diff --git a/app/presenters/two_factor_authentication/phone_selection_presenter.rb b/app/presenters/two_factor_authentication/phone_selection_presenter.rb
index f452e2142e1..1954589607c 100644
--- a/app/presenters/two_factor_authentication/phone_selection_presenter.rb
+++ b/app/presenters/two_factor_authentication/phone_selection_presenter.rb
@@ -21,7 +21,7 @@ def mfa_configuration_count
end
def disabled?
- VendorStatus.new.all_phone_vendor_outage? || user&.phone_configurations&.any?
+ OutageStatus.new.all_phone_vendor_outage? || user&.phone_configurations&.any?
end
end
end
diff --git a/app/presenters/two_factor_authentication/sms_selection_presenter.rb b/app/presenters/two_factor_authentication/sms_selection_presenter.rb
index 081efd9d847..3226ae6adc3 100644
--- a/app/presenters/two_factor_authentication/sms_selection_presenter.rb
+++ b/app/presenters/two_factor_authentication/sms_selection_presenter.rb
@@ -16,7 +16,7 @@ def info
end
def disabled?
- VendorStatus.new.vendor_outage?(:sms)
+ OutageStatus.new.vendor_outage?(:sms)
end
end
end
diff --git a/app/presenters/two_factor_authentication/voice_selection_presenter.rb b/app/presenters/two_factor_authentication/voice_selection_presenter.rb
index b2e4a061785..369878c5e5d 100644
--- a/app/presenters/two_factor_authentication/voice_selection_presenter.rb
+++ b/app/presenters/two_factor_authentication/voice_selection_presenter.rb
@@ -16,7 +16,7 @@ def info
end
def disabled?
- VendorStatus.new.vendor_outage?(:voice)
+ OutageStatus.new.vendor_outage?(:voice)
end
end
end
diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb
index d5fee3f1d58..64a89d83671 100644
--- a/app/services/analytics_events.rb
+++ b/app/services/analytics_events.rb
@@ -646,6 +646,10 @@ def idv_in_person_prepare_submitted(flow_path:, **extra)
track_event('IdV: in person proofing prepare submitted', flow_path: flow_path, **extra)
end
+ def idv_in_person_proofing_residential_address_submitted(**extra)
+ track_event('IdV: in person proofing residential address submitted', **extra)
+ end
+
def idv_in_person_proofing_address_submitted(**extra)
track_event('IdV: in person proofing address submitted', **extra)
end
@@ -729,10 +733,6 @@ def idv_doc_auth_cancel_link_sent_submitted(**extra)
track_event('IdV: doc auth cancel_link_sent submitted', **extra)
end
- def idv_doc_auth_cancel_send_link_submitted(**extra)
- track_event('IdV: doc auth cancel_send_link submitted', **extra)
- end
-
# @identity.idp.previous_event_name IdV: in person proofing cancel_update_ssn submitted
def idv_doc_auth_cancel_update_ssn_submitted(**extra)
track_event('IdV: doc auth cancel_update_ssn submitted', **extra)
@@ -784,14 +784,6 @@ def idv_doc_auth_redo_document_capture_submitted(**extra)
track_event('IdV: doc auth redo_document_capture submitted', **extra)
end
- def idv_doc_auth_send_link_visited(**extra)
- track_event('IdV: doc auth send_link visited', **extra)
- end
-
- def idv_doc_auth_send_link_submitted(**extra)
- track_event('IdV: doc auth send_link submitted', **extra)
- end
-
# @identity.idp.previous_event_name IdV: in person proofing ssn submitted
def idv_doc_auth_ssn_submitted(**extra)
track_event('IdV: doc auth ssn submitted', **extra)
@@ -2108,6 +2100,18 @@ def user_marked_authed(authentication_type:, **extra)
)
end
+ # User has attempted to access an action that requires re-authenticating
+ # @param [String] auth_method
+ # @param [String] authenticated_at
+ def user_2fa_reauthentication_required(auth_method:, authenticated_at:, **extra)
+ track_event(
+ 'User 2FA Reauthentication Required',
+ auth_method: auth_method,
+ authenticated_at: authenticated_at,
+ **extra,
+ )
+ end
+
# User registration has been hadnded off to agency page
# @param [Boolean] ial2
# @param [Integer] ialmax
diff --git a/app/services/flow/base_step.rb b/app/services/flow/base_step.rb
index c6f55650d25..3488e3a1246 100644
--- a/app/services/flow/base_step.rb
+++ b/app/services/flow/base_step.rb
@@ -41,6 +41,8 @@ def url_options
@flow.controller.url_options
end
+ delegate :analytics_visited_event, :analytics_submitted_event, to: :class
+
private
def create_response(form_submit_response, call_response)
diff --git a/app/services/flow/flow_state_machine.rb b/app/services/flow/flow_state_machine.rb
index 7b717e95872..5ffd4358c86 100644
--- a/app/services/flow/flow_state_machine.rb
+++ b/app/services/flow/flow_state_machine.rb
@@ -24,7 +24,7 @@ def update
increment_step_name_counts
analytics.public_send(
- flow.step_handler(step).analytics_submitted_event,
+ flow.step_handler_instance(step).analytics_submitted_event,
**result.to_h.merge(analytics_properties),
)
diff --git a/app/services/idv/actions/cancel_link_sent_action.rb b/app/services/idv/actions/cancel_link_sent_action.rb
index a1d32d19ae2..b38e6c0b4ee 100644
--- a/app/services/idv/actions/cancel_link_sent_action.rb
+++ b/app/services/idv/actions/cancel_link_sent_action.rb
@@ -6,10 +6,7 @@ def self.analytics_submitted_event
end
def call
- mark_step_incomplete(:send_link)
- if IdentityConfig.store.doc_auth_combined_hybrid_handoff_enabled
- mark_step_incomplete(:upload)
- end
+ mark_step_incomplete(:upload)
end
end
end
diff --git a/app/services/idv/actions/cancel_send_link_action.rb b/app/services/idv/actions/cancel_send_link_action.rb
deleted file mode 100644
index 1bf64fefef9..00000000000
--- a/app/services/idv/actions/cancel_send_link_action.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-module Idv
- module Actions
- class CancelSendLinkAction < Idv::Steps::DocAuthBaseStep
- def self.analytics_submitted_event
- :idv_doc_auth_cancel_send_link_submitted
- end
-
- def call
- mark_step_incomplete(:upload)
- end
- end
- end
-end
diff --git a/app/services/idv/flows/doc_auth_flow.rb b/app/services/idv/flows/doc_auth_flow.rb
index 1c9773618c2..ff799eb7ebe 100644
--- a/app/services/idv/flows/doc_auth_flow.rb
+++ b/app/services/idv/flows/doc_auth_flow.rb
@@ -5,8 +5,6 @@ class DocAuthFlow < Flow::BaseFlow
welcome: Idv::Steps::WelcomeStep,
agreement: Idv::Steps::AgreementStep,
upload: Idv::Steps::UploadStep,
- **(IdentityConfig.store.doc_auth_combined_hybrid_handoff_enabled ?
- {} : { send_link: Idv::Steps::SendLinkStep }),
link_sent: Idv::Steps::LinkSentStep,
email_sent: Idv::Steps::EmailSentStep,
document_capture: Idv::Steps::DocumentCaptureStep,
@@ -31,7 +29,6 @@ class DocAuthFlow < Flow::BaseFlow
OPTIONAL_SHOW_STEPS = {}.freeze
ACTIONS = {
- cancel_send_link: Idv::Actions::CancelSendLinkAction,
cancel_link_sent: Idv::Actions::CancelLinkSentAction,
redo_address: Idv::Actions::RedoAddressAction,
redo_document_capture: Idv::Actions::RedoDocumentCaptureAction,
diff --git a/app/services/idv/phone_step.rb b/app/services/idv/phone_step.rb
index d06a1dec178..a8dea4dd8c9 100644
--- a/app/services/idv/phone_step.rb
+++ b/app/services/idv/phone_step.rb
@@ -1,12 +1,16 @@
module Idv
class PhoneStep
- def initialize(idv_session:, trace_id:, attempts_tracker:)
+ def initialize(idv_session:, trace_id:, analytics:, attempts_tracker:)
self.idv_session = idv_session
@trace_id = trace_id
+ @analytics = analytics
@attempts_tracker = attempts_tracker
end
def submit(step_params)
+ return throttled_result if throttle.throttled?
+ throttle.increment!
+
self.step_params = step_params
idv_session.previous_phone_step_params = step_params.slice(:phone, :otp_delivery_preference)
proof_address
@@ -14,6 +18,7 @@ def submit(step_params)
def failure_reason
return :fail if throttle.throttled?
+ return :no_idv_result if idv_result.nil?
return :timeout if idv_result[:timed_out]
return :jobfail if idv_result[:exception].present?
return :warning if idv_result[:success] != true
@@ -34,8 +39,6 @@ def async_state
def async_state_done(async_state)
@idv_result = async_state.result
- throttle.increment! unless failed_due_to_timeout_or_exception?
- @attempts_tracker.idv_phone_otp_sent_rate_limited if throttle.throttled?
success = idv_result[:success]
handle_successful_proofing_attempt if success
@@ -106,6 +109,12 @@ def throttle
@throttle ||= Throttle.new(user: idv_session.current_user, throttle_type: :proof_address)
end
+ def throttled_result
+ @attempts_tracker.idv_phone_otp_sent_rate_limited
+ @analytics.throttler_rate_limit_triggered(throttle_type: :proof_address, step_name: :phone)
+ FormResponse.new(success: false)
+ end
+
def failed_due_to_timeout_or_exception?
idv_result[:timed_out] || idv_result[:exception]
end
diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb
index 685969558c4..4640f44f461 100644
--- a/app/services/idv/session.rb
+++ b/app/services/idv/session.rb
@@ -149,12 +149,16 @@ def phone_confirmed?
def invalidate_steps_after_ssn!
# Guard against unvalidated attributes from in-person flow in review controller
- session[:applicant] = nil
+ clear_applicant!
invalidate_verify_info_step!
invalidate_phone_step!
end
+ def clear_applicant!
+ session[:applicant] = nil
+ end
+
def mark_verify_info_step_complete!
session[:resolution_successful] = true
# This is here to maintain backwards compadibility with old code.
diff --git a/app/services/idv/steps/in_person/address_step.rb b/app/services/idv/steps/in_person/address_step.rb
index 7d642124708..ef6eb5ece71 100644
--- a/app/services/idv/steps/in_person/address_step.rb
+++ b/app/services/idv/steps/in_person/address_step.rb
@@ -8,18 +8,24 @@ def self.analytics_visited_event
:idv_in_person_proofing_address_visited
end
- def self.analytics_submitted_event
- :idv_in_person_proofing_address_submitted
+ def analytics_submitted_event
+ if capture_secondary_id_enabled?
+ :idv_in_person_proofing_residential_address_submitted
+ else
+ :idv_in_person_proofing_address_submitted
+ end
end
def call
Idv::InPerson::AddressForm::ATTRIBUTES.each do |attr|
+ next if attr == :same_address_as_id && capture_secondary_id_enabled?
flow_session[:pii_from_user][attr] = flow_params[attr]
end
end
def extra_view_variables
{
+ capture_secondary_id_enabled: capture_secondary_id_enabled?,
form:,
pii:,
updating_address:,
@@ -28,6 +34,10 @@ def extra_view_variables
private
+ def capture_secondary_id_enabled?
+ current_user.establishing_in_person_enrollment.capture_secondary_id_enabled
+ end
+
def updating_address
flow_session[:pii_from_user].has_key?(:address1)
end
@@ -45,7 +55,8 @@ def flow_params
end
def form
- @form ||= Idv::InPerson::AddressForm.new
+ @form ||= Idv::InPerson::AddressForm.
+ new(capture_secondary_id_enabled: capture_secondary_id_enabled?)
end
def form_submit
diff --git a/app/services/idv/steps/in_person/state_id_step.rb b/app/services/idv/steps/in_person/state_id_step.rb
index 4bbb213826d..2bece07396f 100644
--- a/app/services/idv/steps/in_person/state_id_step.rb
+++ b/app/services/idv/steps/in_person/state_id_step.rb
@@ -24,7 +24,7 @@ def call
def extra_view_variables
{
- capture_secondary_id_enabled:,
+ capture_secondary_id_enabled: capture_secondary_id_enabled?,
form:,
pii:,
parsed_dob:,
@@ -34,7 +34,7 @@ def extra_view_variables
private
- def capture_secondary_id_enabled
+ def capture_secondary_id_enabled?
current_user.establishing_in_person_enrollment.capture_secondary_id_enabled
end
diff --git a/app/services/idv/steps/send_link_step.rb b/app/services/idv/steps/send_link_step.rb
deleted file mode 100644
index 62295b74dfa..00000000000
--- a/app/services/idv/steps/send_link_step.rb
+++ /dev/null
@@ -1,128 +0,0 @@
-module Idv
- module Steps
- class SendLinkStep < DocAuthBaseStep
- include ActionView::Helpers::DateHelper
-
- STEP_INDICATOR_STEP = :verify_id
-
- def self.analytics_visited_event
- :idv_doc_auth_send_link_visited
- end
-
- def self.analytics_submitted_event
- :idv_doc_auth_send_link_submitted
- end
-
- def call
- throttle.increment!
- return throttled_failure if throttle.throttled?
- telephony_result = send_link
- failure_reason = nil
- if !telephony_result.success?
- failure_reason = { telephony: [telephony_result.error.class.name.demodulize] }
- end
- idv_session[:phone_for_mobile_flow] = permit(:phone)[:phone]
- @flow.irs_attempts_api_tracker.idv_phone_upload_link_sent(
- success: telephony_result.success?,
- phone_number: formatted_destination_phone,
- failure_reason: failure_reason,
- )
- build_telephony_form_response(telephony_result)
- end
-
- def extra_view_variables
- {
- idv_phone_form: build_form,
- }
- end
-
- private
-
- def build_form
- Idv::PhoneForm.new(
- previous_params: {},
- user: current_user,
- delivery_methods: [:sms],
- )
- end
-
- def build_telephony_form_response(telephony_result)
- FormResponse.new(
- success: telephony_result.success?,
- errors: { message: telephony_result.error&.friendly_message },
- extra: { telephony_response: telephony_result.to_h },
- )
- end
-
- def throttled_failure
- @flow.analytics.throttler_rate_limit_triggered(
- throttle_type: :idv_send_link,
- )
- message = I18n.t(
- 'errors.doc_auth.send_link_throttle',
- timeout: distance_of_time_in_words(
- Time.zone.now,
- [throttle.expires_at, Time.zone.now].compact.max,
- except: :seconds,
- ),
- )
-
- @flow.irs_attempts_api_tracker.idv_phone_send_link_rate_limited(
- phone_number: formatted_destination_phone,
- )
-
- failure(message)
- end
-
- def send_link
- session_uuid = flow_session[:document_capture_session_uuid]
- update_document_capture_session_requested_at(session_uuid)
- Telephony.send_doc_auth_link(
- to: formatted_destination_phone,
- link: link(session_uuid),
- country_code: Phonelib.parse(formatted_destination_phone).country,
- sp_or_app_name: sp_or_app_name,
- )
- end
-
- def sp_or_app_name
- current_sp&.friendly_name.presence || APP_NAME
- end
-
- def form_submit
- params = permit(:phone)
- params[:otp_delivery_preference] = 'sms'
- build_form.submit(params)
- end
-
- def formatted_destination_phone
- raw_phone = permit(:phone)[:phone]
- PhoneFormatter.format(raw_phone, country_code: 'US')
- end
-
- def update_document_capture_session_requested_at(session_uuid)
- document_capture_session = DocumentCaptureSession.find_by(uuid: session_uuid)
- return unless document_capture_session
- document_capture_session.update!(
- requested_at: Time.zone.now,
- cancelled_at: nil,
- issuer: sp_session[:issuer],
- )
- end
-
- def link(session_uuid)
- idv_capture_doc_dashes_url(
- 'document-capture-session': session_uuid,
- request_id: sp_session[:request_id],
- )
- end
-
- def throttle
- @throttle ||= Throttle.new(
- user: current_user,
- throttle_type: :idv_send_link,
- )
- end
- end
- end
-end
diff --git a/app/services/idv/steps/upload_step.rb b/app/services/idv/steps/upload_step.rb
index 4d4ded41f61..718a145b0ca 100644
--- a/app/services/idv/steps/upload_step.rb
+++ b/app/services/idv/steps/upload_step.rb
@@ -21,27 +21,14 @@ def call
# app/views/idv/doc_auth/upload.html.erb
if params[:type] == 'desktop'
handle_desktop_selection
- elsif params[:combined]
- # The user was shown the new combined view and
- # submitted a phone number to this step with feature flag on
- # OR
- # The user was originally shown the new combined view,
- # but has submitted to a step with the feature flag off
- # (50/50 from new to old)
- handle_phone_submission
else
- handle_mobile_selection
+ return bypass_send_link_steps if mobile_device?
+ handle_phone_submission
end
end
def extra_view_variables
- if IdentityConfig.store.doc_auth_combined_hybrid_handoff_enabled
- {
- idv_phone_form: build_form,
- }
- else
- {}
- end
+ { idv_phone_form: build_form }
end
private
@@ -63,26 +50,13 @@ def build_form
end
def form_submit
- return super if !IdentityConfig.store.doc_auth_combined_hybrid_handoff_enabled
- return super if params[:type] == 'desktop'
-
- # Remove after 50/50 deploy w/ flag
- return super if params[:type] != 'combined'
+ return super unless params[:type] == 'mobile'
params = permit(:phone)
params[:otp_delivery_preference] = 'sms'
build_form.submit(params)
end
- # To be removed after 50/50
- def handle_mobile_selection
- if mobile_device?
- bypass_send_link_steps
- else
- send_user_to_send_link_step
- end
- end
-
def handle_phone_submission
throttle.increment!
return throttled_failure if throttle.throttled?
@@ -125,13 +99,7 @@ def send_user_to_email_sent_step
form_response(destination: :email_sent)
end
- def send_user_to_send_link_step
- mark_step_complete(:email_sent)
- form_response(destination: :send_link)
- end
-
def bypass_send_link_steps
- mark_step_complete(:send_link)
mark_step_complete(:link_sent)
mark_step_complete(:email_sent)
form_response(destination: :document_capture)
diff --git a/app/services/idv/steps/welcome_step.rb b/app/services/idv/steps/welcome_step.rb
index 1a5744a5e94..67116884635 100644
--- a/app/services/idv/steps/welcome_step.rb
+++ b/app/services/idv/steps/welcome_step.rb
@@ -12,7 +12,10 @@ def self.analytics_submitted_event
end
def call
+ flow_session[:skip_upload_step] = true unless FeatureManagement.idv_allow_hybrid_flow?
+
return no_camera_redirect if params[:no_camera]
+
create_document_capture_session(document_capture_session_uuid_key)
cancel_previous_in_person_enrollments
end
diff --git a/app/services/vendor_status.rb b/app/services/outage_status.rb
similarity index 82%
rename from app/services/vendor_status.rb
rename to app/services/outage_status.rb
index 32af74b2370..33a7950ba54 100644
--- a/app/services/vendor_status.rb
+++ b/app/services/outage_status.rb
@@ -1,4 +1,4 @@
-class VendorStatus
+class OutageStatus
include ActionView::Helpers::TranslationHelper
def initialize(from: nil, from_idv: nil, sp: nil)
@@ -7,9 +7,9 @@ def initialize(from: nil, from_idv: nil, sp: nil)
@sp = sp
end
- IAL2_VENDORS = %i[acuant lexisnexis_instant_verify lexisnexis_trueid].freeze
+ IDV_VENDORS = %i[acuant lexisnexis_instant_verify lexisnexis_trueid].freeze
PHONE_VENDORS = %i[sms voice].freeze
- ALL_VENDORS = (IAL2_VENDORS + PHONE_VENDORS).freeze
+ ALL_VENDORS = (IDV_VENDORS + PHONE_VENDORS).freeze
def vendor_outage?(vendor)
status = case vendor
@@ -37,8 +37,8 @@ def all_vendor_outage?(vendors = ALL_VENDORS)
vendors.all? { |vendor| vendor_outage?(vendor) }
end
- def any_ial2_vendor_outage?
- any_vendor_outage?(IAL2_VENDORS)
+ def any_idv_vendor_outage?
+ any_vendor_outage?(IDV_VENDORS)
end
def any_phone_vendor_outage?
@@ -49,6 +49,16 @@ def all_phone_vendor_outage?
all_vendor_outage?(PHONE_VENDORS)
end
+ def gpo_only?
+ IdentityConfig.store.feature_idv_force_gpo_verification_enabled ||
+ any_phone_vendor_outage?
+ end
+
+ def allow_hybrid_flow?
+ IdentityConfig.store.feature_idv_hybrid_flow_enabled &&
+ !any_phone_vendor_outage?
+ end
+
def from_idv?
from_idv
end
@@ -58,7 +68,7 @@ def from_idv?
#
# @return [String, nil] the localized message.
def outage_message
- if any_ial2_vendor_outage?
+ if any_idv_vendor_outage?
if from_idv?
if sp
t('vendor_outage.blocked.idv.with_sp', service_provider: sp.friendly_name)
diff --git a/app/services/usps_in_person_proofing/proofer.rb b/app/services/usps_in_person_proofing/proofer.rb
index 192aa05ac85..367affec59b 100644
--- a/app/services/usps_in_person_proofing/proofer.rb
+++ b/app/services/usps_in_person_proofing/proofer.rb
@@ -1,7 +1,8 @@
module UspsInPersonProofing
class Proofer
AUTH_TOKEN_CACHE_KEY = :usps_ippaas_api_auth_token
- AUTH_TOKEN_REFRESH_THRESHOLD = 5
+ # Automatically refresh our auth token if it is within this many minutes of expiring
+ AUTH_TOKEN_PREEMPTIVE_EXPIRY_MINUTES = 1.minute
# Makes HTTP request to get nearby in-person proofing facilities
# Requires address, city, state and zip code.
@@ -102,14 +103,14 @@ def request_enrollment_code(unique_id)
# @return [String] the token
def retrieve_token!
body = request_token
- expires_at = Time.zone.now + body['expires_in']
+ # Refresh our token early so that it won't expire while a request is in-flight. We expect 15m
+ # expirys for tokens but are careful not to trim the expiry by too much, just in case
+ expires_in = body['expires_in'].seconds
+ if expires_in - AUTH_TOKEN_PREEMPTIVE_EXPIRY_MINUTES > 0
+ expires_in -= AUTH_TOKEN_PREEMPTIVE_EXPIRY_MINUTES
+ end
token = "#{body['token_type']} #{body['access_token']}"
- Rails.cache.write(AUTH_TOKEN_CACHE_KEY, token, expires_at: expires_at)
- # If using a redis cache we have to manually set the expires_at. This is because we aren't
- # using a dedicated Redis cache and instead are just using our existing Redis server with
- # mixed usage patterns. Without this cache entries don't expire.
- # More at https://api.rubyonrails.org/classes/ActiveSupport/Cache/RedisCacheStore.html
- Rails.cache.try(:redis)&.expireat(AUTH_TOKEN_CACHE_KEY, expires_at.to_i)
+ Rails.cache.write(AUTH_TOKEN_CACHE_KEY, token, expires_in: expires_in)
token
end
@@ -148,10 +149,6 @@ def faraday
# already cached.
# @return [Hash] Headers to add to USPS requests
def dynamic_headers
- token_remaining_time = Rails.cache.redis.ttl(AUTH_TOKEN_CACHE_KEY)
- if token_remaining_time != -2 && token_remaining_time <= AUTH_TOKEN_REFRESH_THRESHOLD
- retrieve_token!
- end
{
'Authorization' => token,
'RequestID' => request_id,
diff --git a/app/validators/idv/in_person/form_address_validator.rb b/app/validators/idv/in_person/form_address_validator.rb
index fe302148872..928b377caff 100644
--- a/app/validators/idv/in_person/form_address_validator.rb
+++ b/app/validators/idv/in_person/form_address_validator.rb
@@ -6,7 +6,8 @@ module FormAddressValidator
included do
validates :same_address_as_id,
- presence: true
+ presence: true,
+ unless: :capture_secondary_id_enabled?
validates_with UspsInPersonProofing::TransliterableValidator,
fields: [:city],
diff --git a/app/views/idv/doc_auth/_combined_upload.html.erb b/app/views/idv/doc_auth/_combined_upload.html.erb
deleted file mode 100644
index c3a4b6a2127..00000000000
--- a/app/views/idv/doc_auth/_combined_upload.html.erb
+++ /dev/null
@@ -1,74 +0,0 @@
-<% title t('titles.doc_auth.upload') %>
-
-<%= render 'idv/doc_auth/error_messages', flow_session: flow_session %>
-
-<%= render PageHeadingComponent.new do %>
- <%= t('doc_auth.headings.combined_upload') %>
-<% end %>
-
-
- <%= t('doc_auth.info.combined_upload') %>
-
-
-
-
- <%= image_tag(
- asset_url('idv/phone-icon.svg'),
- alt: t('image_description.camera_mobile_phone'),
- width: 88,
- height: 88,
- ) %>
-
-
-
- <%= t('doc_auth.info.tag') %>
-
-
- <%= t('doc_auth.headings.combined_upload_from_phone') %>
-
- <%= t('doc_auth.info.combined_upload_from_phone') %>
- <%= simple_form_for(
- idv_phone_form,
- as: :doc_auth,
- url: url_for(type: :mobile, combined: true),
- method: 'PUT',
- html: { autocomplete: 'off' },
- ) do |f| %>
- <%= render PhoneInputComponent.new(
- form: f,
- required: true,
- delivery_methods: [:sms],
- class: 'margin-bottom-4',
- ) %>
- <%= f.submit t('forms.buttons.send_link') %>
- <% end %>
-
-
-
-
-
-
- <%= image_tag(
- asset_url('idv/laptop-icon.svg'),
- alt: t('image_description.laptop'),
- width: 88,
- height: 88,
- ) %>
-
-
-
- <%= t('doc_auth.headings.combined_upload_from_computer') %>
-
- <%= t('doc_auth.info.combined_upload_from_computer') %>
- <%= simple_form_for(
- :doc_auth,
- url: url_for(type: :desktop),
- method: 'PUT',
- class: 'margin-bottom-4',
- ) do |f| %>
- <%= f.submit t('forms.buttons.upload_photos'), outline: true %>
- <% end %>
-
-
-
-<%= render 'idv/doc_auth/cancel', step: 'upload' %>
diff --git a/app/views/idv/doc_auth/_upload.html.erb b/app/views/idv/doc_auth/_upload.html.erb
deleted file mode 100644
index 860a2c7e252..00000000000
--- a/app/views/idv/doc_auth/_upload.html.erb
+++ /dev/null
@@ -1,55 +0,0 @@
-<% title t('titles.doc_auth.upload') %>
-
-<%= render 'idv/doc_auth/error_messages', flow_session: flow_session %>
-
-<%= render PageHeadingComponent.new do %>
- <%= t('doc_auth.headings.upload') %>
-<% end %>
-
-
- <%= t('doc_auth.info.upload') %>
-
-
-
-
-
-
- <%= image_tag(
- asset_url('idv/phone-icon.svg'),
- alt: t('image_description.camera_mobile_phone'),
- width: 88,
- height: 88,
- ) %>
-
-
-
- <%= t('doc_auth.info.tag') %>
-
-
- <%= t('doc_auth.headings.upload_from_phone') %>
-
- <%= t('doc_auth.info.upload_from_phone') %>
- <%= simple_form_for(
- :doc_auth,
- url: url_for(type: :mobile),
- method: 'PUT',
- html: { autocomplete: 'off', class: 'margin-top-2' },
- ) do |f| %>
- <%= f.submit t('doc_auth.buttons.use_phone'), wide: false, class: 'margin-top-05' %>
- <% end %>
-
-
-
-
-
-<%= t('doc_auth.info.upload_from_computer') %>
-<%= simple_form_for(
- :doc_auth,
- url: url_for(type: :desktop),
- method: 'PUT',
- html: { class: 'display-inline' },
- ) do |f| %>
- <%= f.submit t('doc_auth.info.upload_computer_link'), unstyled: true, big: false %>
-<% end %>
-
-<%= render 'idv/doc_auth/cancel', step: 'upload' %>
diff --git a/app/views/idv/doc_auth/send_link.html.erb b/app/views/idv/doc_auth/send_link.html.erb
deleted file mode 100644
index 1e49331ba97..00000000000
--- a/app/views/idv/doc_auth/send_link.html.erb
+++ /dev/null
@@ -1,33 +0,0 @@
-<% title t('titles.doc_auth.take_photo') %>
-
-<% if flow_session[:error_message] %>
- <%= render AlertComponent.new(
- type: :error,
- class: 'margin-bottom-4',
- message: flow_session[:error_message],
- ) %>
-<% end %>
-
-<%= render PageHeadingComponent.new.with_content(t('doc_auth.headings.take_picture')) %>
-
-<%= t('doc_auth.info.take_picture') %>
-
-<%= t('doc_auth.info.camera_required') %>
-
-<%= t('doc_auth.instructions.send_sms') %>
-<%= simple_form_for(
- idv_phone_form,
- as: :doc_auth,
- url: url_for,
- method: 'PUT',
- html: { autocomplete: 'off' },
- ) do |f| %>
- <%= render PhoneInputComponent.new(
- form: f,
- required: true,
- delivery_methods: [:sms],
- class: 'margin-bottom-4',
- ) %>
- <%= f.submit t('forms.buttons.continue'), class: 'margin-top-4' %>
-<% end %>
-<%= render 'idv/shared/back', action: 'cancel_send_link' %>
diff --git a/app/views/idv/doc_auth/upload.html.erb b/app/views/idv/doc_auth/upload.html.erb
index 16061aa99d4..c3a4b6a2127 100644
--- a/app/views/idv/doc_auth/upload.html.erb
+++ b/app/views/idv/doc_auth/upload.html.erb
@@ -1,10 +1,74 @@
-<% if IdentityConfig.store.doc_auth_combined_hybrid_handoff_enabled %>
- <%= render partial: 'idv/doc_auth/combined_upload', locals: {
- flow_session: flow_session,
- idv_phone_form: idv_phone_form,
- } %>
-<% else %>
- <%= render partial: 'idv/doc_auth/upload', locals: {
- flow_session: flow_session,
- } %>
+<% title t('titles.doc_auth.upload') %>
+
+<%= render 'idv/doc_auth/error_messages', flow_session: flow_session %>
+
+<%= render PageHeadingComponent.new do %>
+ <%= t('doc_auth.headings.combined_upload') %>
<% end %>
+
+
+ <%= t('doc_auth.info.combined_upload') %>
+
+
+
+
+ <%= image_tag(
+ asset_url('idv/phone-icon.svg'),
+ alt: t('image_description.camera_mobile_phone'),
+ width: 88,
+ height: 88,
+ ) %>
+
+
+
+ <%= t('doc_auth.info.tag') %>
+
+
+ <%= t('doc_auth.headings.combined_upload_from_phone') %>
+
+ <%= t('doc_auth.info.combined_upload_from_phone') %>
+ <%= simple_form_for(
+ idv_phone_form,
+ as: :doc_auth,
+ url: url_for(type: :mobile, combined: true),
+ method: 'PUT',
+ html: { autocomplete: 'off' },
+ ) do |f| %>
+ <%= render PhoneInputComponent.new(
+ form: f,
+ required: true,
+ delivery_methods: [:sms],
+ class: 'margin-bottom-4',
+ ) %>
+ <%= f.submit t('forms.buttons.send_link') %>
+ <% end %>
+
+
+
+
+
+
+ <%= image_tag(
+ asset_url('idv/laptop-icon.svg'),
+ alt: t('image_description.laptop'),
+ width: 88,
+ height: 88,
+ ) %>
+
+
+
+ <%= t('doc_auth.headings.combined_upload_from_computer') %>
+
+ <%= t('doc_auth.info.combined_upload_from_computer') %>
+ <%= simple_form_for(
+ :doc_auth,
+ url: url_for(type: :desktop),
+ method: 'PUT',
+ class: 'margin-bottom-4',
+ ) do |f| %>
+ <%= f.submit t('forms.buttons.upload_photos'), outline: true %>
+ <% end %>
+
+
+
+<%= render 'idv/doc_auth/cancel', step: 'upload' %>
diff --git a/app/views/idv/gpo/index.html.erb b/app/views/idv/gpo/index.html.erb
index ac0e48dd06b..69ec0f3f07b 100644
--- a/app/views/idv/gpo/index.html.erb
+++ b/app/views/idv/gpo/index.html.erb
@@ -42,4 +42,4 @@
],
) %>
-<%= render 'idv/shared/back', fallback_path: @presenter.fallback_back_path %>
+<%= render @presenter.back_or_cancel_partial, @presenter.back_or_cancel_parameters %>
diff --git a/app/views/idv/gpo_only_warning/show.html.erb b/app/views/idv/gpo_only_warning/show.html.erb
new file mode 100644
index 00000000000..78e7a4c77e0
--- /dev/null
+++ b/app/views/idv/gpo_only_warning/show.html.erb
@@ -0,0 +1,53 @@
+<%= render StepIndicatorComponent.new(
+ steps: step_indicator_steps,
+ current_step: :getting_started,
+ locale_scope: 'idv',
+ class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4',
+ ) %>
+
+<%= render StatusPageComponent.new(status: :warning) do |c| %>
+ <% c.header { t('vendor_outage.alerts.pinpoint.idv.header') } %>
+
+ <%= t('vendor_outage.alerts.pinpoint.idv.message_html', app_name: APP_NAME, sp_name: current_sp&.friendly_name || APP_NAME) %>
+
+ <%= t('vendor_outage.alerts.pinpoint.idv.options_prompt') %>
+
+ <% t('vendor_outage.alerts.pinpoint.idv.options_html', status_page_url: StatusPage.base_url).each do | option | %>
+ -
+ <%= option %>
+
+ <% end %>
+
+ <% c.action_button(
+ action: ->(**tag_options, &block) do
+ link_to(idv_doc_auth_step_path(step: :welcome), **tag_options, &block)
+ end,
+ big: true,
+ wide: true,
+ class: 'usa-button',
+ ).with_content(t('doc_auth.buttons.continue')) %>
+ <% c.action_button(
+ action: ->(**tag_options, &block) do
+ link_to(exit_url, **tag_options, &block)
+ end,
+ big: true,
+ wide: true,
+ outline: true,
+ class: 'usa-button',
+ ).with_content(t('links.exit_login', app_name: APP_NAME)) %>
+ <% c.troubleshooting_options do |tc| %>
+ <% tc.header { t('components.troubleshooting_options.default_heading') } %>
+ <% tc.option(
+ url: StatusPage.base_url,
+ new_tab: true,
+ ).with_content(t('vendor_outage.get_updates_on_status_page')) %>
+ <% if decorated_session.sp_name %>
+ <% tc.option(
+ url: current_sp.return_to_sp_url,
+ new_tab: true,
+ ).with_content(
+ t('idv.troubleshooting.options.get_help_at_sp', sp_name: decorated_session.sp_name),
+ ) %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/idv/gpo_verify/index.html.erb b/app/views/idv/gpo_verify/index.html.erb
index 3b57f850770..e9921d693a0 100644
--- a/app/views/idv/gpo_verify/index.html.erb
+++ b/app/views/idv/gpo_verify/index.html.erb
@@ -41,7 +41,7 @@
<% end %>
-<% if FeatureManagement.enable_gpo_verification? && !@mail_spammed %>
+<% if FeatureManagement.gpo_verification_enabled? && !@mail_spammed %>
<%= link_to t('idv.messages.gpo.resend'), idv_gpo_path, class: 'display-block margin-bottom-2' %>
<% end %>
diff --git a/app/views/idv/in_person/address.html.erb b/app/views/idv/in_person/address.html.erb
index c7a9640b682..ec88ca63365 100644
--- a/app/views/idv/in_person/address.html.erb
+++ b/app/views/idv/in_person/address.html.erb
@@ -6,21 +6,35 @@
<%= render PageHeadingComponent.new.with_content(t('in_person_proofing.headings.address')) %>
<% end %>
-
- <%= t('in_person_proofing.body.address.info') %>
- <%= new_window_link_to(
- t('in_person_proofing.body.address.learn_more'),
- MarketingSite.help_center_article_url(
- category: 'verify-your-identity',
- article: 'verify-your-identity-in-person',
- ),
- ) %>
-
+<% unless capture_secondary_id_enabled %>
+
+ <%= t('in_person_proofing.body.address.info') %>
+ <%= new_window_link_to(
+ t('in_person_proofing.body.address.learn_more'),
+ MarketingSite.help_center_article_url(
+ category: 'verify-your-identity',
+ article: 'verify-your-identity-in-person',
+ ),
+ ) %>
+
+<% end %>
<%= simple_form_for(
form, url: url_for, method: 'PUT',
html: { autocomplete: 'off' }
) do |f| %>
+ <% if capture_secondary_id_enabled %>
+ <%= render ValidatedFieldComponent.new(
+ collection: us_states_territories,
+ form: f,
+ label: t('idv.form.state'),
+ label_html: { class: 'usa-label' },
+ name: :state,
+ prompt: t('in_person_proofing.form.address.state_prompt'),
+ required: true,
+ selected: pii[:state],
+ ) %>
+ <% end %>
<%= render ValidatedFieldComponent.new(
form: f,
input_html: { value: pii[:address1] },
@@ -33,7 +47,7 @@
<%= render ValidatedFieldComponent.new(
form: f,
input_html: { value: pii[:address2] },
- label: t('idv.form.address2_optional'),
+ label: capture_secondary_id_enabled ? t('idv.form.address2') : t('idv.form.address2_optional'),
label_html: { class: 'usa-label' },
maxlength: 255,
name: :address2,
@@ -48,16 +62,19 @@
name: :city,
required: true,
) %>
- <%= render ValidatedFieldComponent.new(
- collection: us_states_territories,
- form: f,
- label: t('idv.form.state'),
- label_html: { class: 'usa-label' },
- name: :state,
- prompt: t('in_person_proofing.form.address.state_prompt'),
- required: true,
- selected: pii[:state],
- ) %>
+
+ <% unless capture_secondary_id_enabled %>
+ <%= render ValidatedFieldComponent.new(
+ collection: us_states_territories,
+ form: f,
+ label: t('idv.form.state'),
+ label_html: { class: 'usa-label' },
+ name: :state,
+ prompt: t('in_person_proofing.form.address.state_prompt'),
+ required: true,
+ selected: pii[:state],
+ ) %>
+ <% end %>
<%# using :tel for mobile numeric keypad %>
@@ -74,19 +91,21 @@
) %>
- <%= render ValidatedFieldComponent.new(
- as: :radio_buttons,
- checked: pii[:same_address_as_id],
- collection: [
- [t('in_person_proofing.form.address.same_address_choice_yes'), true],
- [t('in_person_proofing.form.address.same_address_choice_no'), false],
- ],
- form: f,
- label: t('in_person_proofing.form.address.same_address'),
- name: :same_address_as_id,
- required: true,
- wrapper: :uswds_radio_buttons,
- ) %>
+ <% unless capture_secondary_id_enabled %>
+ <%= render ValidatedFieldComponent.new(
+ as: :radio_buttons,
+ checked: pii[:same_address_as_id],
+ collection: [
+ [t('in_person_proofing.form.address.same_address_choice_yes'), true],
+ [t('in_person_proofing.form.address.same_address_choice_no'), false],
+ ],
+ form: f,
+ label: t('in_person_proofing.form.address.same_address'),
+ name: :same_address_as_id,
+ required: true,
+ wrapper: :uswds_radio_buttons,
+ ) %>
+ <% end %>
<%= f.submit class: 'margin-top-1' do %>
<% if updating_address %>
diff --git a/app/views/idv/phone/new.html.erb b/app/views/idv/phone/new.html.erb
index e0a34dec849..2c6e5f38dfc 100644
--- a/app/views/idv/phone/new.html.erb
+++ b/app/views/idv/phone/new.html.erb
@@ -71,8 +71,8 @@
<%= f.radio_button(
:otp_delivery_preference,
:sms,
- checked: !VendorStatus.new.vendor_outage?(:sms), # We want SMS to be default checked
- disabled: VendorStatus.new.vendor_outage?(:sms),
+ checked: !OutageStatus.new.vendor_outage?(:sms), # We want SMS to be default checked
+ disabled: OutageStatus.new.vendor_outage?(:sms),
class: 'usa-radio__input usa-radio__input--bordered',
) %>
<%= f.label :otp_delivery_preference_sms, t('two_factor_authentication.otp_delivery_preference.sms'), class: 'usa-radio__label width-full' %>
@@ -81,7 +81,7 @@
<%= f.radio_button(
:otp_delivery_preference,
:voice,
- disabled: VendorStatus.new.vendor_outage?(:voice),
+ disabled: OutageStatus.new.vendor_outage?(:voice),
class: 'usa-radio__input usa-radio__input--bordered',
) %>
<%= f.label :otp_delivery_preference_voice, t('two_factor_authentication.otp_delivery_preference.voice'), class: 'usa-radio__label width-full' %>
diff --git a/app/views/shared/_sp_alert.html.erb b/app/views/shared/_sp_alert.html.erb
index fdc0ce00877..211785df9b2 100644
--- a/app/views/shared/_sp_alert.html.erb
+++ b/app/views/shared/_sp_alert.html.erb
@@ -1,6 +1,6 @@
<% alert = decorated_session.sp_alert(section) %>
<% if alert %>
- <%= render AlertComponent.new(text_tag: 'div') do %>
+ <%= render AlertComponent.new(text_tag: 'div', class: 'margin-bottom-4') do %>
<%= raw sanitize(alert, tags: %w[a b strong em br p ol ul li], attributes: %w[href target]) %>
<% end %>
<% end %>
diff --git a/app/views/users/shared/_otp_delivery_preference_selection.html.erb b/app/views/users/shared/_otp_delivery_preference_selection.html.erb
index cc3bb484104..3727d029215 100644
--- a/app/views/users/shared/_otp_delivery_preference_selection.html.erb
+++ b/app/views/users/shared/_otp_delivery_preference_selection.html.erb
@@ -14,7 +14,7 @@
form_name_label,
:sms,
form_obj.delivery_preference_sms?,
- disabled: VendorStatus.new.vendor_outage?(:sms),
+ disabled: OutageStatus.new.vendor_outage?(:sms),
class: 'js-otp-delivery-preference usa-radio__input usa-radio__input--bordered',
) %>