diff --git a/Gemfile.lock b/Gemfile.lock
index 632fe2405b1..0cebd12839c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -436,7 +436,7 @@ GEM
net-ssh (6.1.0)
newrelic_rpm (9.7.0)
nio4r (2.7.0)
- nokogiri (1.16.0)
+ nokogiri (1.16.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
openssl (3.0.2)
diff --git a/app/assets/images/email/phone_icon.png b/app/assets/images/email/phone_icon.png
new file mode 100644
index 00000000000..1e44354ba7b
Binary files /dev/null and b/app/assets/images/email/phone_icon.png differ
diff --git a/app/components/base_component.rb b/app/components/base_component.rb
index 05beba4c8ba..684e313b4b1 100644
--- a/app/components/base_component.rb
+++ b/app/components/base_component.rb
@@ -5,7 +5,7 @@ def before_render
def self.scripts
@scripts ||= begin
- scripts = sidecar_files_basenames(['js', 'ts'])
+ scripts = sidecar_files_basenames(['ts'])
scripts.concat superclass.scripts if superclass.respond_to?(:scripts)
scripts
end
diff --git a/app/components/time_component.js b/app/components/time_component.ts
similarity index 100%
rename from app/components/time_component.js
rename to app/components/time_component.ts
diff --git a/app/components/validated_field_component.js b/app/components/validated_field_component.ts
similarity index 100%
rename from app/components/validated_field_component.js
rename to app/components/validated_field_component.ts
diff --git a/app/controllers/account_reset/pending_controller.rb b/app/controllers/account_reset/pending_controller.rb
index 77ca7da3f05..855a6e12452 100644
--- a/app/controllers/account_reset/pending_controller.rb
+++ b/app/controllers/account_reset/pending_controller.rb
@@ -1,6 +1,7 @@
module AccountReset
class PendingController < ApplicationController
include UserAuthenticator
+ include ActionView::Helpers::DateHelper
before_action :authenticate_user
before_action :confirm_account_reset_request_exists
@@ -10,7 +11,9 @@ def show
@pending_presenter = AccountReset::PendingPresenter.new(pending_account_reset_request)
end
- def confirm; end
+ def confirm
+ @account_reset_deletion_period_interval = account_reset_deletion_period_interval
+ end
def cancel
analytics.pending_account_reset_cancelled
@@ -29,5 +32,16 @@ def pending_account_reset_request
current_user,
).call
end
+
+ def account_reset_deletion_period_interval
+ current_time = Time.zone.now
+
+ distance_of_time_in_words(
+ current_time,
+ current_time + IdentityConfig.store.account_reset_wait_period_days.days,
+ true,
+ accumulate_on: :hours,
+ )
+ end
end
end
diff --git a/app/controllers/account_reset/request_controller.rb b/app/controllers/account_reset/request_controller.rb
index 9c0f9dd3099..afc55e4df98 100644
--- a/app/controllers/account_reset/request_controller.rb
+++ b/app/controllers/account_reset/request_controller.rb
@@ -1,11 +1,13 @@
module AccountReset
class RequestController < ApplicationController
include TwoFactorAuthenticatable
+ include ActionView::Helpers::DateHelper
before_action :confirm_two_factor_enabled
def show
analytics.account_reset_visit
+ @account_reset_deletion_period_interval = account_reset_deletion_period_interval
end
def create
@@ -39,5 +41,16 @@ def analytics_attributes
email_addresses: current_user.email_addresses.count,
}
end
+
+ def account_reset_deletion_period_interval
+ current_time = Time.zone.now
+
+ distance_of_time_in_words(
+ current_time,
+ current_time + IdentityConfig.store.account_reset_wait_period_days.days,
+ true,
+ accumulate_on: :hours,
+ )
+ end
end
end
diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb
index 302bc509d58..7c45dc786e9 100644
--- a/app/controllers/concerns/idv/document_capture_concern.rb
+++ b/app/controllers/concerns/idv/document_capture_concern.rb
@@ -36,7 +36,7 @@ def extract_pii_from_doc(user, response, store_in_session: false)
idv_session.had_barcode_read_failure = response.attention_with_barcode?
if store_in_session
idv_session.pii_from_doc = response.pii_from_doc
- idv_session.selfie_check_performed = response.selfie_check_performed
+ idv_session.selfie_check_performed = response.selfie_check_performed?
end
end
@@ -49,7 +49,7 @@ def stored_result
end
def selfie_requirement_met?
- !decorated_sp_session.selfie_required? || stored_result.selfie_check_performed
+ !decorated_sp_session.selfie_required? || stored_result.selfie_check_performed?
end
private
diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb
index 43dbe2b92c7..0e10a12aa91 100644
--- a/app/controllers/concerns/idv/verify_info_concern.rb
+++ b/app/controllers/concerns/idv/verify_info_concern.rb
@@ -112,14 +112,14 @@ def idv_failure_log_rate_limited(rate_limit_type)
def idv_failure_log_error
analytics.idv_doc_auth_exception_visited(
step_name: STEP_NAME,
- remaining_attempts: resolution_rate_limiter.remaining_count,
+ remaining_submit_attempts: resolution_rate_limiter.remaining_count,
)
end
def idv_failure_log_warning
analytics.idv_doc_auth_warning_visited(
step_name: STEP_NAME,
- remaining_attempts: resolution_rate_limiter.remaining_count,
+ remaining_submit_attempts: resolution_rate_limiter.remaining_count,
)
end
diff --git a/app/controllers/concerns/idv_session_concern.rb b/app/controllers/concerns/idv_session_concern.rb
index 0b024537584..08f16cfb9a3 100644
--- a/app/controllers/concerns/idv_session_concern.rb
+++ b/app/controllers/concerns/idv_session_concern.rb
@@ -7,17 +7,20 @@ module IdvSessionConcern
end
def confirm_idv_needed
- return if idv_session_user.active_profile.blank? ||
- decorated_sp_session.requested_more_recent_verification? ||
- idv_session_user.reproof_for_irs?(service_provider: current_sp)
-
- redirect_to idv_activated_url
+ redirect_to idv_activated_url unless idv_needed?
end
def hybrid_session?
session[:doc_capture_user_id].present?
end
+ def idv_needed?
+ user_needs_selfie? ||
+ idv_session_user.active_profile.blank? ||
+ decorated_sp_session.requested_more_recent_verification? ||
+ idv_session_user.reproof_for_irs?(service_provider: current_sp)
+ end
+
def idv_session
@idv_session ||= Idv::Session.new(
user_session: user_session,
@@ -66,4 +69,8 @@ def idv_session_user
current_user
end
+
+ def user_needs_selfie?
+ decorated_sp_session.selfie_required? && !current_user.identity_verified_with_selfie?
+ end
end
diff --git a/app/controllers/idv/how_to_verify_controller.rb b/app/controllers/idv/how_to_verify_controller.rb
index 413a2e6cfb3..f9c14d7a9f0 100644
--- a/app/controllers/idv/how_to_verify_controller.rb
+++ b/app/controllers/idv/how_to_verify_controller.rb
@@ -61,7 +61,9 @@ def self.step_info
controller: self,
next_steps: [:hybrid_handoff, :document_capture],
preconditions: ->(idv_session:, user:) do
- self.enabled? && idv_session.idv_consent_given
+ self.enabled? &&
+ idv_session.idv_consent_given &&
+ idv_session.service_provider&.in_person_proofing_enabled
end,
undo_step: ->(idv_session:, user:) { idv_session.skip_doc_auth = nil },
)
diff --git a/app/controllers/idv/phone_errors_controller.rb b/app/controllers/idv/phone_errors_controller.rb
index 8c6cdfd7fb3..64bcb863185 100644
--- a/app/controllers/idv/phone_errors_controller.rb
+++ b/app/controllers/idv/phone_errors_controller.rb
@@ -10,7 +10,7 @@ class PhoneErrorsController < ApplicationController
before_action :ignore_form_step_wait_requests
def warning
- @remaining_attempts = rate_limiter.remaining_count
+ @remaining_submit_attempts = rate_limiter.remaining_count
if idv_session.previous_phone_step_params
@phone = idv_session.previous_phone_step_params[:phone]
@@ -21,12 +21,12 @@ def warning
end
def timeout
- @remaining_step_attempts = rate_limiter.remaining_count
+ @remaining_submit_attempts = rate_limiter.remaining_count
track_event(type: :timeout)
end
def jobfail
- @remaining_attempts = rate_limiter.remaining_count
+ @remaining_submit_attempts = rate_limiter.remaining_count
track_event(type: :jobfail)
end
@@ -63,7 +63,7 @@ def track_event(type:)
if type == :failure
attributes[:limiter_expires_at] = @expires_at
else
- attributes[:remaining_attempts] = @remaining_attempts
+ attributes[:remaining_submit_attempts] = @remaining_submit_attempts
end
analytics.idv_phone_error_visited(**attributes)
diff --git a/app/controllers/idv/session_errors_controller.rb b/app/controllers/idv/session_errors_controller.rb
index 7fbbc145f75..8c0dd5a9f62 100644
--- a/app/controllers/idv/session_errors_controller.rb
+++ b/app/controllers/idv/session_errors_controller.rb
@@ -20,7 +20,7 @@ def warning
)
@step_indicator_steps = step_indicator_steps
- @remaining_attempts = rate_limiter.remaining_count
+ @remaining_submit_attempts = rate_limiter.remaining_count
log_event(based_on_limiter: rate_limiter)
end
@@ -93,7 +93,7 @@ def log_event(based_on_limiter: nil)
type: params[:action],
}
- options[:attempts_remaining] = based_on_limiter.remaining_count if based_on_limiter
+ options[:submit_attempts_remaining] = based_on_limiter.remaining_count if based_on_limiter
analytics.idv_session_error_visited(**options)
end
diff --git a/app/controllers/idv_controller.rb b/app/controllers/idv_controller.rb
index a86a445ebc3..767ad6019e8 100644
--- a/app/controllers/idv_controller.rb
+++ b/app/controllers/idv_controller.rb
@@ -10,10 +10,7 @@ class IdvController < ApplicationController
before_action :confirm_not_rate_limited
def index
- if decorated_sp_session.requested_more_recent_verification? ||
- current_user.reproof_for_irs?(service_provider: current_sp)
- verify_identity
- elsif active_profile?
+ if already_verified?
redirect_to idv_activated_url
else
verify_identity
@@ -32,6 +29,14 @@ def activated
private
+ def already_verified?
+ if decorated_sp_session.selfie_required?
+ return current_user.identity_verified_with_selfie?
+ end
+
+ return current_user.active_profile.present?
+ end
+
def verify_identity
analytics.idv_intro_visit
redirect_to idv_welcome_url
diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb
index 298fc37f259..d8bd370c509 100644
--- a/app/controllers/users/webauthn_setup_controller.rb
+++ b/app/controllers/users/webauthn_setup_controller.rb
@@ -90,28 +90,6 @@ def confirm
end
end
- def delete
- if MfaPolicy.new(current_user).multiple_factors_enabled?
- handle_successful_delete
- else
- handle_failed_delete
- end
- redirect_to account_two_factor_authentication_path
- end
-
- def show_delete
- @webauthn = WebauthnConfiguration.where(
- user_id: current_user.id, id: delete_params[:id],
- ).first
-
- if @webauthn
- render 'users/webauthn_setup/delete'
- else
- flash[:error] = t('errors.general')
- redirect_back fallback_location: new_user_session_url, allow_other_host: false
- end
- end
-
private
def validate_existing_platform_authenticator
@@ -142,35 +120,6 @@ def exclude_credentials
current_user.webauthn_configurations.map(&:credential_id)
end
- def handle_successful_delete
- webauthn = WebauthnConfiguration.find_by(user_id: current_user.id, id: delete_params[:id])
- return unless webauthn
-
- create_user_event(:webauthn_key_removed)
- webauthn.destroy
- revoke_remember_device(current_user)
- event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user)
- PushNotification::HttpPush.deliver(event)
- if webauthn.platform_authenticator
- flash[:success] = t('notices.webauthn_platform_deleted')
- else
- flash[:success] = t('notices.webauthn_deleted')
- end
- track_delete(success: true, platform_authenticator: webauthn.platform_authenticator?)
- end
-
- def handle_failed_delete
- track_delete(success: false, platform_authenticator: nil)
- end
-
- def track_delete(success:, platform_authenticator:)
- analytics.webauthn_delete_submitted(
- success:,
- configuration_id: delete_params[:id],
- platform_authenticator:,
- )
- end
-
def save_challenge_in_session
credential_creation_options = WebAuthn::Credential.options_for_create(user: current_user)
user_session[:webauthn_challenge] = credential_creation_options.challenge.bytes.to_a
@@ -224,9 +173,5 @@ def confirm_params
:transports,
)
end
-
- def delete_params
- params.permit(:id)
- end
end
end
diff --git a/app/forms/gpo_verify_form.rb b/app/forms/gpo_verify_form.rb
index cb19d512430..5ef87a0f890 100644
--- a/app/forms/gpo_verify_form.rb
+++ b/app/forms/gpo_verify_form.rb
@@ -41,7 +41,7 @@ def submit
enqueued_at: gpo_confirmation_code&.code_sent_at,
which_letter: which_letter,
letter_count: letter_count,
- attempts: attempts,
+ submit_attempts: submit_attempts,
pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]],
pending_in_person_enrollment: !!pending_profile&.in_person_enrollment&.pending?,
fraud_check_failed: fraud_check_failed,
@@ -76,7 +76,7 @@ def letter_count
pending_profile&.gpo_confirmation_codes&.count
end
- def attempts
+ def submit_attempts
RateLimiter.new(user: user, rate_limit_type: :verify_gpo_key).attempts
end
diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb
index 37399c27cbd..37f2bd30269 100644
--- a/app/forms/idv/api_image_upload_form.rb
+++ b/app/forms/idv/api_image_upload_form.rb
@@ -144,10 +144,10 @@ def doc_side_classification(client_response)
def extra_attributes
return @extra_attributes if defined?(@extra_attributes) &&
- @extra_attributes&.dig('attempts') == attempts
+ @extra_attributes&.dig('submit_attempts') == submit_attempts
@extra_attributes = {
- attempts: attempts,
- remaining_attempts: remaining_attempts,
+ submit_attempts: submit_attempts,
+ remaining_submit_attempts: remaining_submit_attempts,
user_id: user_uuid,
pii_like_keypaths: DocPiiForm.pii_like_keypaths,
flow_path: params[:flow_path],
@@ -155,6 +155,7 @@ def extra_attributes
@extra_attributes[:front_image_fingerprint] = front_image_fingerprint
@extra_attributes[:back_image_fingerprint] = back_image_fingerprint
+ @extra_attributes[:selfie_image_fingerprint] = selfie_image_fingerprint
@extra_attributes[:liveness_checking_required] = liveness_checking_required
@extra_attributes
end
@@ -175,11 +176,21 @@ def back_image_fingerprint
end
end
- def remaining_attempts
+ def selfie_image_fingerprint
+ return unless liveness_checking_required
+ return @selfie_image_fingerprint if @selfie_image_fingerprint
+
+ if readable?(:selfie)
+ @selfie_image_fingerprint =
+ Digest::SHA256.urlsafe_base64digest(selfie_image_bytes)
+ end
+ end
+
+ def remaining_submit_attempts
rate_limiter.remaining_count if document_capture_session
end
- def attempts
+ def submit_attempts
rate_limiter.attempts if document_capture_session
end
@@ -262,6 +273,15 @@ def validate_duplicate_images
side: error_sides.length == 2 ? 'both' : error_sides[0], **extra_attributes,
)
end
+
+ if capture_result&.failed_selfie_image?(selfie_image_fingerprint)
+ errors.add(
+ :selfie, t('doc_auth.errors.doc.resubmit_failed_image'), type: :duplicate_image
+ )
+ analytics.idv_doc_auth_failed_image_resubmitted(
+ side: 'selfie', **extra_attributes,
+ )
+ end
end
def limit_if_rate_limited
@@ -352,12 +372,15 @@ def acuant_sdk_upgrade_ab_test_data
def acuant_sdk_capture?
image_metadata.dig(:front, :source) == Idp::Constants::Vendors::ACUANT &&
- image_metadata.dig(:back, :source) == Idp::Constants::Vendors::ACUANT
+ image_metadata.dig(:back, :source) == Idp::Constants::Vendors::ACUANT &&
+ (liveness_checking_required ?
+ image_metadata.dig(:selfie, :source) == Idp::Constants::Vendors::ACUANT :
+ true)
end
def image_metadata
- @image_metadata ||= params.permit(:front_image_metadata, :back_image_metadata).
- to_h.
+ @image_metadata ||= params.
+ permit(:front_image_metadata, :back_image_metadata, :selfie_image_metadata).to_h.
transform_values do |str|
JSON.parse(str)
rescue JSON::ParserError
@@ -439,35 +462,39 @@ def store_failed_images(client_response, doc_pii_response)
return {
front: [],
back: [],
+ selfie: [],
}
end
# doc auth failed due to non network error or doc_pii is not valid
if client_response && !client_response.success? && !client_response.network_error?
errors_hash = client_response.errors&.to_h || {}
- ## assume both sides' error presents or both sides' error missing
- failed_front_fingerprint = extra_attributes[:front_image_fingerprint]
- failed_back_fingerprint = extra_attributes[:back_image_fingerprint]
- ## not both sides' error present nor both sides' error missing
- ## equivalent to: only one side error presents
- only_one_side_error = errors_hash[:front]&.present? ^ errors_hash[:back]&.present?
- if only_one_side_error
- ## find which side is missing
- failed_front_fingerprint = nil unless errors_hash[:front]&.present?
- failed_back_fingerprint = nil unless errors_hash[:back]&.present?
+ failed_front_fingerprint = nil
+ failed_back_fingerprint = nil
+ if errors_hash[:front] || errors_hash[:back]
+ if errors_hash[:front]
+ failed_front_fingerprint = extra_attributes[:front_image_fingerprint]
+ end
+ if errors_hash[:back]
+ failed_back_fingerprint = extra_attributes[:back_image_fingerprint]
+ end
+ elsif !client_response.doc_auth_success?
+ failed_front_fingerprint = extra_attributes[:front_image_fingerprint]
+ failed_back_fingerprint = extra_attributes[:back_image_fingerprint]
end
- document_capture_session.
- store_failed_auth_data(
- front_image_fingerprint: failed_front_fingerprint,
- back_image_fingerprint: failed_back_fingerprint,
- doc_auth_success: client_response.doc_auth_success?,
- selfie_status: selfie_status_from_response(client_response),
- )
+ document_capture_session.store_failed_auth_data(
+ front_image_fingerprint: failed_front_fingerprint,
+ back_image_fingerprint: failed_back_fingerprint,
+ selfie_image_fingerprint: extra_attributes[:selfie_image_fingerprint],
+ doc_auth_success: client_response.doc_auth_success?,
+ selfie_status: client_response.selfie_status,
+ )
elsif doc_pii_response && !doc_pii_response.success?
document_capture_session.store_failed_auth_data(
front_image_fingerprint: extra_attributes[:front_image_fingerprint],
back_image_fingerprint: extra_attributes[:back_image_fingerprint],
+ selfie_image_fingerprint: extra_attributes[:selfie_image_fingerprint],
doc_auth_success: client_response.doc_auth_success?,
- selfie_status: selfie_status_from_response(client_response),
+ selfie_status: client_response.selfie_status,
)
end
# retrieve updated data from session
@@ -475,6 +502,7 @@ def store_failed_images(client_response, doc_pii_response)
{
front: captured_result&.failed_front_image_fingerprints || [],
back: captured_result&.failed_back_image_fingerprints || [],
+ selfie: captured_result&.failed_selfie_image_fingerprints || [],
}
end
diff --git a/app/forms/idv/doc_pii_form.rb b/app/forms/idv/doc_pii_form.rb
index 4cca182ca60..96e02d64797 100644
--- a/app/forms/idv/doc_pii_form.rb
+++ b/app/forms/idv/doc_pii_form.rb
@@ -7,12 +7,7 @@ class DocPiiForm
validates_presence_of :address1, { message: proc {
I18n.t('doc_auth.errors.alerts.address_check')
} }
- validates :zipcode, format: {
- with: /\A[0-9]{5}(?:-[0-9]{4})?\z/,
- message: proc {
- I18n.t('doc_auth.errors.general.no_liveness')
- },
- }
+ validate :zipcode_valid?
validates :jurisdiction, :state, inclusion: { in: Idp::Constants::STATE_AND_TERRITORY_CODES,
message: proc {
I18n.t('doc_auth.errors.general.no_liveness')
@@ -88,6 +83,12 @@ def dob_valid?
end
end
+ def zipcode_valid?
+ return if zipcode.is_a?(String) && zipcode.present?
+
+ errors.add(:zipcode, generic_error, type: :zipcode)
+ end
+
def generic_error
I18n.t('doc_auth.errors.general.no_liveness')
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 3e434470734..7b0eb6c382a 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -60,10 +60,4 @@ def cancel_link_text
def desktop_device?
!BrowserCache.parse(request.user_agent).mobile?
end
-
- def selfie_status_from_response(client_response)
- return client_response.selfie_status if client_response.respond_to?(:selfie_status)
-
- :not_processed
- end
end
diff --git a/app/helpers/script_helper.rb b/app/helpers/script_helper.rb
index 5600061a3eb..1c9ce9925d0 100644
--- a/app/helpers/script_helper.rb
+++ b/app/helpers/script_helper.rb
@@ -6,33 +6,28 @@ def javascript_include_tag_without_preload(...)
without_preload_links_header { javascript_include_tag(...) }
end
- def javascript_packs_tag_once(*names, prepend: false)
- @scripts ||= []
- if prepend
- @scripts = names | @scripts
- else
- @scripts |= names
- end
+ def javascript_packs_tag_once(*names, **attributes)
+ @scripts = @scripts.to_h.merge(names.index_with(attributes))
nil
end
alias_method :enqueue_component_scripts, :javascript_packs_tag_once
- def render_javascript_pack_once_tags(*names)
- names = names.presence || @scripts
- if names && (sources = AssetSources.get_sources(*names)).present?
- safe_join(
- [
- javascript_assets_tag(*names),
- *sources.map do |source|
- javascript_include_tag(
- source,
- crossorigin: local_crossorigin_sources? ? true : nil,
- integrity: AssetSources.get_integrity(source),
- )
- end,
- ],
- )
+ def render_javascript_pack_once_tags(...)
+ capture do
+ javascript_packs_tag_once(...)
+ return if @scripts.blank?
+ concat javascript_assets_tag
+ @scripts.each do |name, attributes|
+ AssetSources.get_sources(name).each do |source|
+ concat javascript_include_tag(
+ source,
+ **attributes,
+ crossorigin: local_crossorigin_sources? ? true : nil,
+ integrity: AssetSources.get_integrity(source),
+ )
+ end
+ end
end
end
@@ -46,8 +41,8 @@ def local_crossorigin_sources?
Rails.env.development? && ENV['WEBPACK_PORT'].present?
end
- def javascript_assets_tag(*names)
- assets = AssetSources.get_assets(*names)
+ def javascript_assets_tag
+ assets = AssetSources.get_assets(*@scripts.keys)
if assets.present?
asset_map = assets.index_with { |path| asset_path(path, host: asset_host(path)) }
content_tag(
diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx
index a9f22662c2b..53e002c57ed 100644
--- a/app/javascript/packages/document-capture/components/acuant-capture.tsx
+++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx
@@ -53,9 +53,9 @@ interface ImageAnalyticsPayload {
*/
source: ImageSource;
/**
- * Total number of attempts at this point
+ * Total number of attempts to capture / upload an image at this point
*/
- attempt?: number;
+ captureAttempts?: number;
/**
* Size of the image in bytes
*/
@@ -334,7 +334,7 @@ function AcuantCapture(
useMemo(() => setOwnErrorMessage(null), [value]);
const { isMobile } = useContext(DeviceContext);
const { t, formatHTML } = useI18n();
- const [attempt, incrementAttempt] = useCounter(1);
+ const [captureAttempts, incrementCaptureAttempts] = useCounter(1);
const [acuantFailureCookie, setAcuantFailureCookie, refreshAcuantFailureCookie] =
useCookie('AcuantCameraHasFailed');
const [imageCaptureText, setImageCaptureText] = useState('');
@@ -384,10 +384,10 @@ function AcuantCapture(
>(payload: P): P {
const enhancedPayload = {
...payload,
- attempt,
+ captureAttempts,
acuantCaptureMode: payload.source === 'upload' ? null : acuantCaptureMode,
};
- incrementAttempt();
+ incrementCaptureAttempts();
return enhancedPayload;
}
@@ -516,7 +516,7 @@ function AcuantCapture(
}
function onSelfieCaptureSuccess({ image }: { image: string }) {
- trackEvent('idv_sdk_selfie_image_added', { attempt });
+ trackEvent('idv_sdk_selfie_image_added', { captureAttempts });
onChangeAndResetError(image);
onResetFailedCaptureAttempts();
diff --git a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx
index 84c74fbcba0..cf67436e688 100644
--- a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx
+++ b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx
@@ -17,14 +17,16 @@ import type { ReviewIssuesStepValue } from './review-issues-step';
interface DocumentCaptureReviewIssuesProps extends FormStepComponentProps {
isFailedDocType: boolean;
- remainingAttempts: number;
+ isFailedSelfieLivenessOrQuality: boolean;
+ remainingSubmitAttempts: number;
captureHints: boolean;
hasDismissed: boolean;
}
function DocumentCaptureReviewIssues({
isFailedDocType,
- remainingAttempts = Infinity,
+ isFailedSelfieLivenessOrQuality,
+ remainingSubmitAttempts = Infinity,
captureHints,
registerField = () => undefined,
unknownFieldErrors = [],
@@ -50,8 +52,10 @@ function DocumentCaptureReviewIssues({
diff --git a/app/javascript/packages/document-capture/components/document-capture-warning.tsx b/app/javascript/packages/document-capture/components/document-capture-warning.tsx
index c7944a15eb5..adb5cda4463 100644
--- a/app/javascript/packages/document-capture/components/document-capture-warning.tsx
+++ b/app/javascript/packages/document-capture/components/document-capture-warning.tsx
@@ -2,6 +2,7 @@ import { Cancel } from '@18f/identity-verify-flow';
import { useI18n, HtmlTextWithStrongNoWrap } from '@18f/identity-react-i18n';
import { useContext, useEffect, useRef } from 'react';
import { FormStepError } from '@18f/identity-form-steps';
+import type { I18n } from '@18f/identity-i18n';
import Warning from './warning';
import DocumentCaptureTroubleshootingOptions from './document-capture-troubleshooting-options';
import UnknownError from './unknown-error';
@@ -11,7 +12,8 @@ import AnalyticsContext from '../context/analytics';
interface DocumentCaptureWarningProps {
isFailedDocType: boolean;
isFailedResult: boolean;
- remainingAttempts: number;
+ isFailedSelfieLivenessOrQuality: boolean;
+ remainingSubmitAttempts: number;
actionOnClick?: () => void;
unknownFieldErrors: FormStepError<{ front: string; back: string }>[];
hasDismissed: boolean;
@@ -19,10 +21,26 @@ interface DocumentCaptureWarningProps {
const DISPLAY_ATTEMPTS = 3;
+type GetHeadingArguments = {
+ isFailedDocType: boolean;
+ isFailedSelfieLivenessOrQuality: boolean;
+ t: typeof I18n.prototype.t;
+};
+function getHeading({ isFailedDocType, isFailedSelfieLivenessOrQuality, t }: GetHeadingArguments) {
+ if (isFailedDocType) {
+ return t('errors.doc_auth.doc_type_not_supported_heading');
+ }
+ if (isFailedSelfieLivenessOrQuality) {
+ return t('errors.doc_auth.selfie_not_live_or_poor_quality_heading');
+ }
+ return t('errors.doc_auth.rate_limited_heading');
+}
+
function DocumentCaptureWarning({
isFailedDocType,
isFailedResult,
- remainingAttempts,
+ isFailedSelfieLivenessOrQuality,
+ remainingSubmitAttempts,
actionOnClick,
unknownFieldErrors = [],
hasDismissed,
@@ -32,15 +50,13 @@ function DocumentCaptureWarning({
const { trackEvent } = useContext(AnalyticsContext);
const nonIppOrFailedResult = !inPersonURL || isFailedResult;
- const heading = isFailedDocType
- ? t('errors.doc_auth.doc_type_not_supported_heading')
- : t('errors.doc_auth.rate_limited_heading');
+ const heading = getHeading({ isFailedDocType, isFailedSelfieLivenessOrQuality, t });
const actionText = nonIppOrFailedResult
? t('idv.failure.button.warning')
: t('idv.failure.button.try_online');
- const subheading = !nonIppOrFailedResult && !isFailedDocType && (
- {t('errors.doc_auth.rate_limited_subheading')}
- );
+ const subheading = !nonIppOrFailedResult &&
+ !isFailedDocType &&
+ !isFailedSelfieLivenessOrQuality && {t('errors.doc_auth.rate_limited_subheading')}
;
const subheadingRef = useRef(null);
const errorMessageDisplayedRef = useRef(null);
@@ -50,7 +66,7 @@ function DocumentCaptureWarning({
trackEvent('IdV: warning shown', {
location: 'doc_auth_review_issues',
- remaining_attempts: remainingAttempts,
+ remaining_submit_attempts: remainingSubmitAttempts,
heading,
subheading: subheadingText,
error_message_displayed: errorMessageDisplayed,
@@ -77,19 +93,22 @@ function DocumentCaptureWarning({
- {!isFailedDocType && remainingAttempts <= DISPLAY_ATTEMPTS && (
-
-
-
- )}
+ {!isFailedDocType &&
+ !isFailedSelfieLivenessOrQuality &&
+ remainingSubmitAttempts <= DISPLAY_ATTEMPTS && (
+
+
+
+ )}
{nonIppOrFailedResult && }
>
diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx
index c591d0e3472..82daac11372 100644
--- a/app/javascript/packages/document-capture/components/document-capture.tsx
+++ b/app/javascript/packages/document-capture/components/document-capture.tsx
@@ -112,9 +112,11 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) {
form:
submissionError instanceof UploadFormEntriesError
? withProps({
- remainingAttempts: submissionError.remainingAttempts,
+ remainingSubmitAttempts: submissionError.remainingSubmitAttempts,
isFailedResult: submissionError.isFailedResult,
isFailedDocType: submissionError.isFailedDocType,
+ isFailedSelfieLivenessOrQuality:
+ submissionError.selfieNotLive || submissionError.selfieNotGoodQuality,
captureHints: submissionError.hints,
pii: submissionError.pii,
failedImageFingerprints: submissionError.failed_image_fingerprints,
diff --git a/app/javascript/packages/document-capture/components/review-issues-step.tsx b/app/javascript/packages/document-capture/components/review-issues-step.tsx
index 82e777548f7..e44a2c0c163 100644
--- a/app/javascript/packages/document-capture/components/review-issues-step.tsx
+++ b/app/javascript/packages/document-capture/components/review-issues-step.tsx
@@ -36,9 +36,10 @@ export interface ReviewIssuesStepValue {
}
interface ReviewIssuesStepProps extends FormStepComponentProps {
- remainingAttempts?: number;
+ remainingSubmitAttempts?: number;
isFailedResult?: boolean;
isFailedDocType?: boolean;
+ isFailedSelfieLivenessOrQuality?: boolean;
captureHints?: boolean;
pii?: PII;
failedImageFingerprints?: { front: string[] | null; back: string[] | null };
@@ -52,15 +53,16 @@ function ReviewIssuesStep({
onError = () => {},
registerField = () => undefined,
toPreviousStep = () => undefined,
- remainingAttempts = Infinity,
+ remainingSubmitAttempts = Infinity,
isFailedResult = false,
isFailedDocType = false,
+ isFailedSelfieLivenessOrQuality = false,
pii,
captureHints = false,
failedImageFingerprints = { front: [], back: [] },
}: ReviewIssuesStepProps) {
const { trackEvent } = useContext(AnalyticsContext);
- const [hasDismissed, setHasDismissed] = useState(remainingAttempts === Infinity);
+ const [hasDismissed, setHasDismissed] = useState(remainingSubmitAttempts === Infinity);
const { onPageTransition, changeStepCanComplete } = useContext(FormStepsContext);
const [skipWarning, setSkipWarning] = useState(false);
useDidUpdateEffect(onPageTransition, [hasDismissed]);
@@ -118,7 +120,8 @@ function ReviewIssuesStep({
{
unknownFieldErrors: FormStepError<{ front: string; back: string }>[];
isFailedDocType: boolean;
- remainingAttempts: number;
+ isFailedSelfieLivenessOrQuality: boolean;
+ remainingSubmitAttempts: number;
altFailedDocTypeMsg?: string | null;
+ altIsFailedSelfieDontIncludeAttempts?: boolean;
hasDismissed: boolean;
}
@@ -24,11 +26,27 @@ function formatIdTypeMsg({ altFailedDocTypeMsg, acceptedIdUrl }) {
});
}
+type GetErrorArguments = {
+ unknownFieldErrors: FormStepError<{ front: string; back: string }>[];
+};
+function getError({ unknownFieldErrors }: GetErrorArguments) {
+ const errs =
+ !!unknownFieldErrors &&
+ // Errors where the field than is not 'front' or 'back'. In practice this means the field
+ // should be from the 'general' field in the "IdV: doc auth image upload vendor submitted" event
+ unknownFieldErrors.filter((error) => !['front', 'back'].includes(error.field!));
+ const err = errs.length !== 0 ? errs[0].error : null;
+
+ return err;
+}
+
function UnknownError({
unknownFieldErrors = [],
isFailedDocType = false,
- remainingAttempts,
+ isFailedSelfieLivenessOrQuality = false,
+ remainingSubmitAttempts,
altFailedDocTypeMsg = null,
+ altIsFailedSelfieDontIncludeAttempts = false,
hasDismissed,
}: UnknownErrorProps) {
const { t } = useI18n();
@@ -45,10 +63,8 @@ function UnknownError({
location: 'document_capture_review_issues',
});
- const errs =
- !!unknownFieldErrors &&
- unknownFieldErrors.filter((error) => !['front', 'back'].includes(error.field!));
- const err = errs.length !== 0 ? errs[0].error : null;
+ const err = getError({ unknownFieldErrors });
+
if (isFailedDocType && !!altFailedDocTypeMsg) {
return (
{formatIdTypeMsg({ altFailedDocTypeMsg, acceptedIdUrl })}
@@ -56,14 +72,28 @@ function UnknownError({
}
if (isFailedDocType && err) {
return (
-
+
{err.message}{' '}
);
}
+ if (isFailedSelfieLivenessOrQuality && err) {
+ return (
+ <>
+ {err.message}
+
+ {!altIsFailedSelfieDontIncludeAttempts && (
+
+ )}
+
+ >
+ );
+ }
if (err && !hasDismissed) {
return {err.message}
;
}
diff --git a/app/javascript/packages/document-capture/context/upload.tsx b/app/javascript/packages/document-capture/context/upload.tsx
index 6f6879cfee7..2ad859295ff 100644
--- a/app/javascript/packages/document-capture/context/upload.tsx
+++ b/app/javascript/packages/document-capture/context/upload.tsx
@@ -78,7 +78,7 @@ export interface UploadErrorResponse {
/**
* Number of remaining doc capture attempts for user.
*/
- remaining_attempts?: number;
+ remaining_submit_attempts?: number;
/**
* Boolean to decide if capture hints should be shown with error.
@@ -100,6 +100,16 @@ export interface UploadErrorResponse {
*/
doc_type_supported: boolean;
+ /*
+ * Whether the selfie passed the liveness check from trueid
+ */
+ selfie_live?: boolean;
+
+ /*
+ * Whether the selfie passed the quality check from trueid.
+ */
+ selfie_quality_good?: boolean;
+
/**
* Record of failed image fingerprints
*/
diff --git a/app/javascript/packages/document-capture/services/upload.ts b/app/javascript/packages/document-capture/services/upload.ts
index ed0c77e16ed..27b18db733b 100644
--- a/app/javascript/packages/document-capture/services/upload.ts
+++ b/app/javascript/packages/document-capture/services/upload.ts
@@ -36,12 +36,16 @@ export class UploadFormEntryError extends FormError {
export class UploadFormEntriesError extends FormError {
formEntryErrors: UploadFormEntryError[] = [];
- remainingAttempts = Infinity;
+ remainingSubmitAttempts = Infinity;
isFailedResult = false;
isFailedDocType = false;
+ selfieNotLive = false;
+
+ selfieNotGoodQuality = false;
+
pii?: PII;
hints = false;
@@ -108,8 +112,8 @@ const upload: UploadImplementation = async function (payload, { method = 'POST',
error.formEntryErrors = result.errors.map(toFormEntryError);
}
- if (result.remaining_attempts) {
- error.remainingAttempts = result.remaining_attempts;
+ if (result.remaining_submit_attempts) {
+ error.remainingSubmitAttempts = result.remaining_submit_attempts;
}
if (result.ocr_pii) {
@@ -124,6 +128,11 @@ const upload: UploadImplementation = async function (payload, { method = 'POST',
error.isFailedDocType = !result.doc_type_supported;
+ error.selfieNotLive = result.selfie_live === undefined ? false : !result.selfie_live;
+
+ error.selfieNotGoodQuality =
+ result.selfie_quality_good === undefined ? false : !result.selfie_quality_good;
+
error.failed_image_fingerprints = result.failed_image_fingerprints ?? { front: [], back: [] };
throw error;
diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx
index 6877d2c6d2e..cf883d4eac1 100644
--- a/app/javascript/packages/form-steps/form-steps.spec.tsx
+++ b/app/javascript/packages/form-steps/form-steps.spec.tsx
@@ -355,12 +355,6 @@ describe('FormSteps', () => {
expect(window.location.hash).to.equal('#second');
});
- it('resets hash in URL if there is no matching step', () => {
- window.location.hash = '#example';
- render();
- expect(window.location.hash).to.equal('');
- });
-
it('syncs step by history events', async () => {
const { getByText, findByText, getByLabelText } = render();
diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx
index d0c96c7e0e8..f454b303388 100644
--- a/app/javascript/packages/form-steps/form-steps.tsx
+++ b/app/javascript/packages/form-steps/form-steps.tsx
@@ -231,10 +231,11 @@ function FormSteps({
promptOnNavigate = true,
titleFormat,
}: FormStepsProps) {
+ const stepNames = steps.map((step) => step.name);
const [values, setValues] = useState(initialValues);
const [activeErrors, setActiveErrors] = useState(initialActiveErrors);
const formRef = useRef(null as HTMLFormElement | null);
- const [stepName, setStepName] = useHistoryParam(initialStep);
+ const [stepName, setStepName] = useHistoryParam(initialStep, stepNames);
const [stepErrors, setStepErrors] = useState([] as Error[]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [stepCanComplete, setStepCanComplete] = useState(undefined);
diff --git a/app/javascript/packages/form-steps/use-history-param.spec.tsx b/app/javascript/packages/form-steps/use-history-param.spec.tsx
index 94c83bd7061..cf9be56e18d 100644
--- a/app/javascript/packages/form-steps/use-history-param.spec.tsx
+++ b/app/javascript/packages/form-steps/use-history-param.spec.tsx
@@ -1,4 +1,4 @@
-import { render } from '@testing-library/react';
+import { render, act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';
import useHistoryParam, { getStepParam } from './use-history-param';
@@ -13,8 +13,14 @@ describe('getStepParam', () => {
});
describe('useHistoryParam', () => {
- function TestComponent({ initialValue }: { initialValue?: string }) {
- const [count = 0, setCount] = useHistoryParam(initialValue);
+ function TestComponent({
+ initialValue,
+ validValues,
+ }: {
+ initialValue?: string;
+ validValues?: string[];
+ }) {
+ const [count = 0, setCount] = useHistoryParam(initialValue, validValues);
return (
<>
@@ -119,4 +125,41 @@ describe('useHistoryParam', () => {
const [path2] = inst2.result.current;
expect(path2).to.equal('root');
});
+
+ context('when specifying valid values', () => {
+ it('syncs by history events for a valid value', async () => {
+ const { getByText, getByDisplayValue, findByDisplayValue } = render(
+ ,
+ );
+ expect(getByDisplayValue('0')).to.be.ok();
+
+ await userEvent.click(getByText('Increment'));
+
+ expect(getByDisplayValue('1')).to.be.ok();
+ expect(window.location.hash).to.equal('#1');
+
+ act(() => {
+ window.history.back();
+ });
+
+ expect(await findByDisplayValue('0')).to.be.ok();
+ expect(window.location.hash).to.equal('');
+ });
+
+ it('maintains value (does not sync) by history events for an invalid value', async () => {
+ const { getByDisplayValue } = render();
+ expect(getByDisplayValue('0')).to.be.ok();
+ const popstateHandled = new Promise((resolve) =>
+ window.addEventListener('popstate', resolve, { once: true }),
+ );
+
+ act(() => {
+ window.location.hash = '#wrong';
+ });
+
+ await popstateHandled;
+ expect(getByDisplayValue('0')).to.be.ok();
+ expect(window.location.hash).to.equal('#wrong');
+ });
+ });
});
diff --git a/app/javascript/packages/form-steps/use-history-param.ts b/app/javascript/packages/form-steps/use-history-param.ts
index 1839840924d..7f91b3609ff 100644
--- a/app/javascript/packages/form-steps/use-history-param.ts
+++ b/app/javascript/packages/form-steps/use-history-param.ts
@@ -29,13 +29,17 @@ const subscribers: Array<() => void> = [];
*/
function useHistoryParam(
initialValue?: string,
+ validValues?: string[],
): [string | undefined, (nextParamValue: ParamValue) => void] {
- function getCurrentValue(): ParamValue {
+ function getCurrentValue(currentValue?: string): ParamValue {
const path = window.location.hash.slice(1);
if (path) {
- return getStepParam(path);
+ const value = getStepParam(path);
+ return !validValues || validValues.includes(value) ? value : currentValue;
}
+
+ return initialValue;
}
const [value, setValue] = useState(initialValue ?? getCurrentValue);
diff --git a/app/javascript/packages/phone-input/package.json b/app/javascript/packages/phone-input/package.json
index b360a4f2f6a..56504a875cd 100644
--- a/app/javascript/packages/phone-input/package.json
+++ b/app/javascript/packages/phone-input/package.json
@@ -4,6 +4,6 @@
"version": "1.0.0",
"dependencies": {
"intl-tel-input": "^17.0.19",
- "libphonenumber-js": "^1.10.54"
+ "libphonenumber-js": "^1.10.55"
}
}
diff --git a/app/javascript/packs/application.ts b/app/javascript/packs/application.ts
index 6cd8cb725d5..ad2ce264d1c 100644
--- a/app/javascript/packs/application.ts
+++ b/app/javascript/packs/application.ts
@@ -2,8 +2,3 @@ import { accordion, banner, skipnav } from '@18f/identity-design-system';
const components = [accordion, banner, skipnav];
components.forEach((component) => component.on());
-const mainContent = document.getElementById('main-content');
-document.querySelector('.usa-skipnav')?.addEventListener('click', (event) => {
- event.preventDefault();
- mainContent?.scrollIntoView();
-});
diff --git a/app/javascript/packs/masked-text-toggle.js b/app/javascript/packs/masked-text-toggle.js
deleted file mode 100644
index ad14e181850..00000000000
--- a/app/javascript/packs/masked-text-toggle.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import MaskedTextToggle from '@18f/identity-masked-text-toggle';
-
-const wrappers = document.querySelectorAll('.masked-text__toggle');
-wrappers.forEach((toggle) => new MaskedTextToggle(/** @type {HTMLInputElement} */ (toggle)).bind());
diff --git a/app/javascript/packs/masked-text-toggle.ts b/app/javascript/packs/masked-text-toggle.ts
new file mode 100644
index 00000000000..ecf9fa6b07f
--- /dev/null
+++ b/app/javascript/packs/masked-text-toggle.ts
@@ -0,0 +1,4 @@
+import MaskedTextToggle from '@18f/identity-masked-text-toggle';
+
+const wrappers = document.querySelectorAll('.masked-text__toggle');
+wrappers.forEach((toggle) => new MaskedTextToggle(toggle).bind());
diff --git a/app/javascript/packs/otp-delivery-preference.js b/app/javascript/packs/otp-delivery-preference.ts
similarity index 57%
rename from app/javascript/packs/otp-delivery-preference.js
rename to app/javascript/packs/otp-delivery-preference.ts
index 4576e00799c..49a42871b0b 100644
--- a/app/javascript/packs/otp-delivery-preference.js
+++ b/app/javascript/packs/otp-delivery-preference.ts
@@ -1,46 +1,31 @@
import { t } from '@18f/identity-i18n';
-
-/** @typedef {import('@18f/identity-phone-input').PhoneInputElement} PhoneInput */
+import type { PhoneInputElement } from '@18f/identity-phone-input';
/**
* Returns the OTP delivery preference element.
- *
- * @return {HTMLElement}
*/
-const getOTPDeliveryMethodContainer = () =>
- /** @type {HTMLElement} */ (document.querySelector('.js-otp-delivery-preferences'));
+const getOTPDeliveryMethodContainer = (): HTMLElement =>
+ document.querySelector('.js-otp-delivery-preferences')!;
-/**
- * @return {HTMLInputElement[]}
- */
const getOTPDeliveryMethods = () =>
- Array.from(document.querySelectorAll('.js-otp-delivery-preference'));
+ Array.from(document.querySelectorAll('.js-otp-delivery-preference'));
/**
* Returns true if the delivery option is valid for the selected option, or false otherwise.
- *
- * @param {string} delivery
- * @param {HTMLOptionElement} selectedOption
- * @return {boolean}
*/
-const isDeliveryOptionSupported = (delivery, selectedOption) =>
+const isDeliveryOptionSupported = (delivery: string, selectedOption: HTMLOptionElement): boolean =>
selectedOption.getAttribute(`data-supports-${delivery}`) !== 'false';
-/**
- * @param {string} delivery
- * @param {string} location
- * @return {string=}
- */
-const getHintTextForDisabledDeliveryOption = (delivery, location) =>
+const getHintTextForDisabledDeliveryOption = (
+ delivery: string,
+ location: string,
+): string | undefined =>
// i18n-tasks-use t('two_factor_authentication.otp_delivery_preference.voice_unsupported')
// i18n-tasks-use t('two_factor_authentication.otp_delivery_preference.sms_unsupported')
t(`two_factor_authentication.otp_delivery_preference.${delivery}_unsupported`, { location });
-/**
- * @param {string=} hintText
- */
function setHintText(
- hintText = t('two_factor_authentication.otp_delivery_preference.instruction'),
+ hintText: string = t('two_factor_authentication.otp_delivery_preference.instruction'),
) {
const hintElement = document.querySelector('#otp_delivery_preference_instruction');
if (hintElement) {
@@ -50,38 +35,31 @@ function setHintText(
/**
* Returns true if all inputs are disabled, or false otherwise.
- *
- * @param {HTMLInputElement[]} inputs
- * @return {boolean}
*/
-const isAllDisabled = (inputs) => inputs.every((input) => input.disabled);
+const isAllDisabled = (inputs: HTMLInputElement[]): boolean =>
+ inputs.every((input) => input.disabled);
/**
* Returns the next non-disabled input in the set of inputs, if one exists.
- *
- * @param {HTMLInputElement[]} inputs
- * @return {HTMLInputElement=}
*/
-const getFirstEnabledInput = (inputs) => inputs.find((input) => !input.disabled);
+const getFirstEnabledInput = (inputs: HTMLInputElement[]): HTMLInputElement | undefined =>
+ inputs.find((input) => !input.disabled);
/**
* Toggles the delivery preferences selection visible or hidden.
*
- * @param {boolean} isVisible Whether the selection element should be visible.
+ * @param isVisible Whether the selection element should be visible.
*/
-const toggleDeliveryPreferencesVisible = (isVisible) =>
+const toggleDeliveryPreferencesVisible = (isVisible: boolean) =>
getOTPDeliveryMethodContainer().classList.toggle('display-none', !isVisible);
-/**
- * @param {Event} event
- */
-function updateOTPDeliveryMethods(event) {
+function updateOTPDeliveryMethods(event: Event) {
if (!(event.target instanceof HTMLSelectElement)) {
return;
}
const { target: select, currentTarget } = event;
- const { textInput } = /** @type {PhoneInput} */ (currentTarget);
+ const { textInput } = currentTarget as PhoneInputElement;
if (!textInput) {
return;
}
@@ -90,7 +68,7 @@ function updateOTPDeliveryMethods(event) {
const methods = getOTPDeliveryMethods();
setHintText();
- const location = /** @type {string} */ (selectedOption.dataset.countryName);
+ const location = selectedOption.dataset.countryName!;
methods.forEach((method) => {
const delivery = method.value;
@@ -113,7 +91,6 @@ function updateOTPDeliveryMethods(event) {
toggleDeliveryPreferencesVisible(!isAllMethodsDisabled);
}
-document.querySelectorAll('lg-phone-input').forEach((node) => {
- const phoneInput = /** @type {PhoneInput} */ (node);
+document.querySelectorAll('lg-phone-input').forEach((phoneInput) => {
phoneInput.addEventListener('change', updateOTPDeliveryMethods);
});
diff --git a/app/javascript/packs/session-expire-session.js b/app/javascript/packs/session-expire-session.ts
similarity index 100%
rename from app/javascript/packs/session-expire-session.js
rename to app/javascript/packs/session-expire-session.ts
diff --git a/app/jobs/get_usps_proofing_results_job.rb b/app/jobs/get_usps_proofing_results_job.rb
index fe136a9409b..51355517576 100644
--- a/app/jobs/get_usps_proofing_results_job.rb
+++ b/app/jobs/get_usps_proofing_results_job.rb
@@ -391,6 +391,31 @@ def handle_successful_status_update(enrollment, response)
end
end
+ def handle_passed_with_fraud_review_pending(enrollment, response)
+ proofed_at = parse_usps_timestamp(response['transactionEndDateTime'])
+ enrollment_outcomes[:enrollments_passed] += 1
+ analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_updated(
+ **enrollment_analytics_attributes(enrollment, complete: true),
+ **response_analytics_attributes(response),
+ passed: true,
+ reason: 'Passed with fraud pending',
+ job_name: self.class.name,
+ )
+ enrollment.update(
+ status: :passed,
+ proofed_at: proofed_at,
+ status_check_completed_at: Time.zone.now,
+ )
+
+ # send email
+ send_please_call_email(enrollment.user, enrollment)
+ analytics(user: enrollment.user).
+ idv_in_person_usps_proofing_results_job_please_call_email_initiated(
+ **email_analytics_attributes(enrollment),
+ job_name: self.class.name,
+ )
+ end
+
def handle_unsupported_secondary_id(enrollment, response)
proofed_at = parse_usps_timestamp(response['transactionEndDateTime'])
enrollment_outcomes[:enrollments_failed] += 1
@@ -435,7 +460,9 @@ def process_enrollment_response(enrollment, response)
case response['status']
when IPP_STATUS_PASSED
- if passed_with_unsupported_secondary_id_type?(response)
+ if fraud_result_pending?(enrollment)
+ handle_passed_with_fraud_review_pending(enrollment, response)
+ elsif passed_with_unsupported_secondary_id_type?(response)
handle_unsupported_secondary_id(enrollment, response)
elsif SUPPORTED_ID_TYPES.include?(response['primaryIdType'])
handle_successful_status_update(enrollment, response)
@@ -489,6 +516,16 @@ def send_failed_fraud_email(user, enrollment)
end
end
+ def send_please_call_email(user, enrollment)
+ user.confirmed_email_addresses.each do |email_address|
+ # rubocop:disable IdentityIdp/MailLaterLinter
+ UserMailer.with(user: user, email_address: email_address).in_person_please_call(
+ enrollment: enrollment,
+ ).deliver_later(**notification_delivery_params(enrollment))
+ # rubocop:enable IdentityIdp/MailLaterLinter
+ end
+ end
+
# enqueue sms notification job when it's expired or success
# @param [InPersonEnrollment] enrollment
def send_enrollment_status_sms_notification(enrollment:)
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index cc49ebb6a47..7f858170de6 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -15,6 +15,7 @@
class UserMailer < ActionMailer::Base
include Mailable
include LocaleHelper
+ include ActionView::Helpers::DateHelper
class UserEmailAddressMismatchError < StandardError; end
@@ -149,7 +150,11 @@ def personal_key_regenerated
def account_reset_request(account_reset)
with_user_locale(user) do
@token = account_reset&.request_token
- @header = t('user_mailer.account_reset_request.header')
+ @account_reset_deletion_period_hours = account_reset_deletion_period_hours
+ @header = t(
+ 'user_mailer.account_reset_request.header',
+ interval: account_reset_deletion_period_interval,
+ )
mail(
to: email_address.email,
subject: t('user_mailer.account_reset_request.subject', app_name: APP_NAME),
@@ -161,6 +166,8 @@ def account_reset_granted(account_reset)
with_user_locale(user) do
@token = account_reset&.request_token
@granted_token = account_reset&.granted_token
+ @account_reset_deletion_period_hours = account_reset_deletion_period_hours
+ @account_reset_token_valid_period = account_reset_token_valid_period
mail(
to: email_address.email,
subject: t('user_mailer.account_reset_granted.subject', app_name: APP_NAME),
@@ -364,6 +371,20 @@ def in_person_failed_fraud(enrollment:)
end
end
+ def in_person_please_call(enrollment:)
+ with_user_locale(user) do
+ @presenter = Idv::InPerson::VerificationResultsEmailPresenter.new(
+ enrollment: enrollment,
+ url_options: url_options,
+ )
+ @hide_title = true
+ mail(
+ to: email_address.email,
+ subject: t('user_mailer.in_person_please_call.subject', app_name: APP_NAME),
+ )
+ end
+ end
+
def in_person_outage_notification(enrollment:)
with_user_locale(user) do
@presenter = Idv::InPerson::VerificationResultsEmailPresenter.new(
@@ -430,4 +451,30 @@ def email_should_receive_nonessential_notifications?(email)
modified_email = email.gsub(/\+[^@]+@/, '@')
!banlist.include?(modified_email)
end
+
+ def account_reset_deletion_period_interval
+ current_time = Time.zone.now
+
+ distance_of_time_in_words(
+ current_time,
+ current_time + IdentityConfig.store.account_reset_wait_period_days.days,
+ true,
+ accumulate_on: :hours,
+ )
+ end
+
+ def account_reset_deletion_period_hours
+ IdentityConfig.store.account_reset_wait_period_days.days.in_hours.to_i
+ end
+
+ def account_reset_token_valid_period
+ current_time = Time.zone.now
+
+ distance_of_time_in_words(
+ current_time,
+ current_time + IdentityConfig.store.account_reset_token_valid_for_days.days,
+ true,
+ accumulate_on: :hours,
+ )
+ end
end
diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb
index dd36db9aae5..c2a42e5c118 100644
--- a/app/models/document_capture_session.rb
+++ b/app/models/document_capture_session.rb
@@ -17,9 +17,8 @@ def store_result_from_response(doc_auth_response)
session_result.pii = doc_auth_response.pii_from_doc
session_result.captured_at = Time.zone.now
session_result.attention_with_barcode = doc_auth_response.attention_with_barcode?
- session_result.selfie_check_performed = doc_auth_response.selfie_check_performed?
session_result.doc_auth_success = doc_auth_response.doc_auth_success?
- session_result.selfie_status = selfie_status_from_response(doc_auth_response)
+ session_result.selfie_status = doc_auth_response.selfie_status
EncryptedRedisStructStorage.store(
session_result,
expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.seconds.to_i,
@@ -28,8 +27,8 @@ def store_result_from_response(doc_auth_response)
save!
end
- def store_failed_auth_data(front_image_fingerprint:, back_image_fingerprint:, doc_auth_success:,
- selfie_status:)
+ def store_failed_auth_data(front_image_fingerprint:, back_image_fingerprint:,
+ selfie_image_fingerprint:, doc_auth_success:, selfie_status:)
session_result = load_result || DocumentCaptureSessionResult.new(
id: generate_result_id,
)
@@ -37,8 +36,11 @@ def store_failed_auth_data(front_image_fingerprint:, back_image_fingerprint:, do
session_result.captured_at = Time.zone.now
session_result.doc_auth_success = doc_auth_success
session_result.selfie_status = selfie_status
- session_result.add_failed_front_image!(front_image_fingerprint) if front_image_fingerprint
- session_result.add_failed_back_image!(back_image_fingerprint) if back_image_fingerprint
+
+ session_result.add_failed_front_image!(front_image_fingerprint)
+ session_result.add_failed_back_image!(back_image_fingerprint)
+ session_result.add_failed_selfie_image!(selfie_image_fingerprint) if selfie_status == :fail
+
EncryptedRedisStructStorage.store(
session_result,
expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.seconds.to_i,
diff --git a/app/models/service_provider_request.rb b/app/models/service_provider_request.rb
index d3e132c9068..61e5365b83f 100644
--- a/app/models/service_provider_request.rb
+++ b/app/models/service_provider_request.rb
@@ -3,7 +3,7 @@ class ServiceProviderRequest
# since these objects are serialized to/from Redis and may be present
# upon deployment
attr_accessor :uuid, :issuer, :url, :ial, :aal, :requested_attributes,
- :biometric_comparison_required
+ :biometric_comparison_required, :acr_values, :vtr
def initialize(
uuid: nil,
@@ -13,8 +13,8 @@ def initialize(
aal: nil,
requested_attributes: [],
biometric_comparison_required: false,
- acr_values: nil, # rubocop:disable Lint/UnusedMethodArgument
- vtr: nil # rubocop:disable Lint/UnusedMethodArgument
+ acr_values: nil,
+ vtr: nil
)
@uuid = uuid
@issuer = issuer
@@ -23,6 +23,8 @@ def initialize(
@aal = aal
@requested_attributes = requested_attributes&.map(&:to_s)
@biometric_comparison_required = biometric_comparison_required
+ @acr_values = acr_values
+ @vtr = vtr
end
def ==(other)
diff --git a/app/presenters/account_reset/pending_presenter.rb b/app/presenters/account_reset/pending_presenter.rb
index c93eca3a809..636b7bcd081 100644
--- a/app/presenters/account_reset/pending_presenter.rb
+++ b/app/presenters/account_reset/pending_presenter.rb
@@ -18,5 +18,9 @@ def time_remaining_until_granted(now: Time.zone.now)
highest_measures: 2,
)
end
+
+ def account_reset_deletion_period_hours
+ IdentityConfig.store.account_reset_wait_period_days.days.in_hours.to_i
+ end
end
end
diff --git a/app/presenters/image_upload_response_presenter.rb b/app/presenters/image_upload_response_presenter.rb
index cc1b8d9f7ca..d2b7ceab314 100644
--- a/app/presenters/image_upload_response_presenter.rb
+++ b/app/presenters/image_upload_response_presenter.rb
@@ -20,8 +20,8 @@ def errors
end
end
- def remaining_attempts
- @form_response.to_h[:remaining_attempts]
+ def remaining_submit_attempts
+ @form_response.to_h[:remaining_submit_attempts]
end
def status
@@ -40,9 +40,9 @@ def as_json(*)
else
json = { success: false,
errors: errors,
- remaining_attempts: remaining_attempts,
+ remaining_submit_attempts: remaining_submit_attempts,
doc_type_supported: doc_type_supported? }
- if remaining_attempts&.zero?
+ if remaining_submit_attempts&.zero?
if @form_response.extra[:flow_path] == 'standard'
json[:redirect] = idv_session_errors_rate_limited_url
else # hybrid flow on mobile
@@ -89,7 +89,7 @@ def doc_type_supported?
end
def failed_fingerprints
- @form_response.extra[:failed_image_fingerprints] || { front: [], back: [] }
+ @form_response.extra[:failed_image_fingerprints] || { front: [], back: [], selfie: [] }
end
def show_selfie_failures
diff --git a/app/presenters/two_factor_login_options_presenter.rb b/app/presenters/two_factor_login_options_presenter.rb
index da9b171d195..0d9556a4168 100644
--- a/app/presenters/two_factor_login_options_presenter.rb
+++ b/app/presenters/two_factor_login_options_presenter.rb
@@ -115,7 +115,10 @@ def account_reset_url(locale:)
def account_reset_cancel_link
safe_join(
[
- t('two_factor_authentication.account_reset.pending'),
+ t(
+ 'two_factor_authentication.account_reset.pending',
+ interval: account_reset_deletion_period_interval,
+ ),
@view.link_to(
t('two_factor_authentication.account_reset.cancel_link'),
account_reset_cancel_url(token: account_reset_token),
@@ -140,4 +143,15 @@ def sp_name
APP_NAME
end
end
+
+ def account_reset_deletion_period_interval
+ current_time = Time.zone.now
+
+ view.distance_of_time_in_words(
+ current_time,
+ current_time + IdentityConfig.store.account_reset_wait_period_days.days,
+ true,
+ accumulate_on: :hours,
+ )
+ end
end
diff --git a/app/services/account_reset/create_request.rb b/app/services/account_reset/create_request.rb
index 7d2dba04e4e..bbe2d05de2b 100644
--- a/app/services/account_reset/create_request.rb
+++ b/app/services/account_reset/create_request.rb
@@ -1,5 +1,7 @@
module AccountReset
class CreateRequest
+ include ActionView::Helpers::DateHelper
+
def initialize(user, requesting_issuer)
@user = user
@requesting_issuer = requesting_issuer
@@ -46,11 +48,23 @@ def notify_user_by_sms_if_applicable
@telephony_response = Telephony.send_account_reset_notice(
to: phone,
country_code: Phonelib.parse(phone).country,
+ interval: account_reset_wait_period,
)
end
def extra_analytics_attributes
@telephony_response&.extra&.slice(:request_id, :message_id) || {}
end
+
+ def account_reset_wait_period
+ current_time = Time.zone.now
+
+ distance_of_time_in_words(
+ current_time,
+ current_time + IdentityConfig.store.account_reset_wait_period_days,
+ true,
+ accumulate_on: :hours,
+ )
+ end
end
end
diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb
index eff2e191771..7f392be8655 100644
--- a/app/services/analytics_events.rb
+++ b/app/services/analytics_events.rb
@@ -678,7 +678,8 @@ def idv_address_visit
# @param [Boolean] acuant_sdk_upgrade_a_b_testing_enabled
# @param [String] acuant_version
# @param [Boolean] assessment
- # @param [Integer] attempt number of attempts
+ # @param [Integer] captureAttempts number of attempts to capture / upload an image
+ # (previously called "attempt")
# @param [String] documentType
# @param [Integer] dpi dots per inch of image
# @param [Integer] failedImageResubmission
@@ -705,7 +706,7 @@ def idv_back_image_added(
acuant_sdk_upgrade_a_b_testing_enabled:,
acuant_version:,
assessment:,
- attempt:,
+ captureAttempts:,
documentType:,
dpi:,
failedImageResubmission:,
@@ -733,7 +734,7 @@ def idv_back_image_added(
acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled,
acuant_version: acuant_version,
assessment: assessment,
- attempt: attempt,
+ captureAttempts: captureAttempts,
documentType: documentType,
dpi: dpi,
failedImageResubmission: failedImageResubmission,
@@ -893,14 +894,14 @@ def idv_doc_auth_document_capture_visited(**extra)
end
# @param [String] step_name which step the user was on
- # @param [Integer] remaining_attempts how many attempts the user has left before
- # we rate limit them
+ # @param [Integer] remaining_submit_attempts how many attempts the user has left before
+ # we rate limit them (previously called "remaining_attempts")
# The user visited an error page due to an encountering an exception talking to a proofing vendor
- def idv_doc_auth_exception_visited(step_name:, remaining_attempts:, **extra)
+ def idv_doc_auth_exception_visited(step_name:, remaining_submit_attempts:, **extra)
track_event(
'IdV: doc auth exception visited',
step_name: step_name,
- remaining_attempts: remaining_attempts,
+ remaining_submit_attempts: remaining_submit_attempts,
**extra,
)
end
@@ -968,8 +969,8 @@ def idv_doc_auth_ssn_visited(**extra)
# @param [Boolean] success
# @param [Hash] errors
- # @param [Integer] attempts
- # @param [Integer] remaining_attempts
+ # @param [Integer] submit_attempts (previously called "attempts")
+ # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts")
# @param [String] user_id
# @param [String] flow_path
# @param [String] front_image_fingerprint Fingerprint of front image data
@@ -978,9 +979,9 @@ def idv_doc_auth_ssn_visited(**extra)
def idv_doc_auth_submitted_image_upload_form(
success:,
errors:,
- remaining_attempts:,
+ remaining_submit_attempts:,
flow_path:,
- attempts: nil,
+ submit_attempts: nil,
user_id: nil,
front_image_fingerprint: nil,
back_image_fingerprint: nil,
@@ -990,8 +991,8 @@ def idv_doc_auth_submitted_image_upload_form(
'IdV: doc auth image upload form submitted',
success: success,
errors: errors,
- attempts: attempts,
- remaining_attempts: remaining_attempts,
+ submit_attempts: submit_attempts,
+ remaining_submit_attempts: remaining_submit_attempts,
user_id: user_id,
flow_path: flow_path,
front_image_fingerprint: front_image_fingerprint,
@@ -1008,8 +1009,8 @@ def idv_doc_auth_submitted_image_upload_form(
# @param [String] state
# @param [String] state_id_type
# @param [Boolean] async
- # @param [Integer] attempts
- # @param [Integer] remaining_attempts
+ # @param [Integer] submit_attempts (previously called "attempts")
+ # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts")
# @param [Hash] client_image_metrics
# @param [String] flow_path
# @param [Float] vendor_request_time_in_ms Time it took to upload images & get a response.
@@ -1018,7 +1019,7 @@ def idv_doc_auth_submitted_image_upload_form(
# @param [Boolean] attention_with_barcode
# @param [Boolean] doc_type_supported
# @param [Boolean] doc_auth_success
- # @param [Boolean] selfie_success
+ # @param [String] selfie_status
# @param [String] vendor
# @param [String] conversation_id
# @param [String] reference
@@ -1054,8 +1055,8 @@ def idv_doc_auth_submitted_image_upload_vendor(
state:,
state_id_type:,
async:,
- attempts:,
- remaining_attempts:,
+ submit_attempts:,
+ remaining_submit_attempts:,
client_image_metrics:,
flow_path:,
billed: nil,
@@ -1066,7 +1067,7 @@ def idv_doc_auth_submitted_image_upload_vendor(
attention_with_barcode: nil,
doc_type_supported: nil,
doc_auth_success: nil,
- selfie_success: nil,
+ selfie_status: nil,
vendor: nil,
conversation_id: nil,
reference: nil,
@@ -1092,8 +1093,8 @@ def idv_doc_auth_submitted_image_upload_vendor(
state:,
state_id_type:,
async:,
- attempts:,
- remaining_attempts:,
+ submit_attempts: submit_attempts,
+ remaining_submit_attempts: remaining_submit_attempts,
client_image_metrics:,
flow_path:,
vendor_request_time_in_ms:,
@@ -1102,7 +1103,7 @@ def idv_doc_auth_submitted_image_upload_vendor(
attention_with_barcode:,
doc_type_supported:,
doc_auth_success:,
- selfie_success:,
+ selfie_status:,
vendor:,
conversation_id:,
reference:,
@@ -1123,7 +1124,7 @@ def idv_doc_auth_submitted_image_upload_vendor(
# @param [Boolean] success
# @param [Hash] errors
# @param [String] user_id
- # @param [Integer] remaining_attempts
+ # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts")
# @param [Hash] pii_like_keypaths
# @param [String] flow_path
# @param [String] front_image_fingerprint Fingerprint of front image data
@@ -1133,7 +1134,7 @@ def idv_doc_auth_submitted_image_upload_vendor(
def idv_doc_auth_submitted_pii_validation(
success:,
errors:,
- remaining_attempts:,
+ remaining_submit_attempts:,
pii_like_keypaths:,
flow_path:,
user_id: nil,
@@ -1147,7 +1148,7 @@ def idv_doc_auth_submitted_pii_validation(
success: success,
errors: errors,
user_id: user_id,
- remaining_attempts: remaining_attempts,
+ remaining_submit_attempts: remaining_submit_attempts,
pii_like_keypaths: pii_like_keypaths,
flow_path: flow_path,
front_image_fingerprint: front_image_fingerprint,
@@ -1172,13 +1173,13 @@ def idv_doc_auth_verify_visited(**extra)
end
# @param [String] step_name
- # @param [Integer] remaining_attempts
+ # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts")
# The user was sent to a warning page during the IDV flow
- def idv_doc_auth_warning_visited(step_name:, remaining_attempts:, **extra)
+ def idv_doc_auth_warning_visited(step_name:, remaining_submit_attempts:, **extra)
track_event(
'IdV: doc auth warning visited',
step_name: step_name,
- remaining_attempts: remaining_attempts,
+ remaining_submit_attempts: remaining_submit_attempts,
**extra,
)
end
@@ -1323,7 +1324,8 @@ def idv_forgot_password_confirmed(proofing_components: nil, **extra)
# @param [Boolean] acuant_sdk_upgrade_a_b_testing_enabled
# @param [String] acuant_version
# @param [Boolean] assessment
- # @param [Integer] attempt number of attempts
+ # @param [Integer] captureAttempts number of attempts to capture / upload an image
+ # (previously called "attempt")
# @param [String] documentType
# @param [Integer] dpi dots per inch of image
# @param [Integer] failedImageResubmission
@@ -1350,7 +1352,7 @@ def idv_front_image_added(
acuant_sdk_upgrade_a_b_testing_enabled:,
acuant_version:,
assessment:,
- attempt:,
+ captureAttempts:,
documentType:,
dpi:,
failedImageResubmission:,
@@ -1378,7 +1380,7 @@ def idv_front_image_added(
acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled,
acuant_version: acuant_version,
assessment: assessment,
- attempt: attempt,
+ captureAttempts: captureAttempts,
documentType: documentType,
dpi: dpi,
failedImageResubmission: failedImageResubmission,
@@ -2273,6 +2275,16 @@ def idv_in_person_usps_proofing_results_job_exception(
)
end
+ # Tracks please call emails that are initiated during GetUspsProofingResultsJob
+ def idv_in_person_usps_proofing_results_job_please_call_email_initiated(
+ **extra
+ )
+ track_event(
+ :idv_in_person_usps_proofing_results_job_please_call_email_initiated,
+ **extra,
+ )
+ end
+
# GetUspsProofingResultsJob is beginning. Includes some metadata about what the job will do
# @param [Integer] enrollments_count number of enrollments eligible for status check
# @param [Integer] reprocess_delay_minutes minimum delay since last status check
@@ -2698,14 +2710,15 @@ def idv_phone_confirmation_vendor_submitted(
# @param ['warning','jobfail','failure'] type
# @param [Time] limiter_expires_at when the rate limit expires
- # @param [Integer] remaining_attempts number of attempts remaining
+ # @param [Integer] remaining_submit_attempts number of submit attempts remaining
+ # (previously called "remaining_attempts")
# @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# When a user gets an error during the phone finder flow of IDV
def idv_phone_error_visited(
type:,
proofing_components: nil,
limiter_expires_at: nil,
- remaining_attempts: nil,
+ remaining_submit_attempts: nil,
**extra
)
track_event(
@@ -2714,7 +2727,7 @@ def idv_phone_error_visited(
type: type,
proofing_components: proofing_components,
limiter_expires_at: limiter_expires_at,
- remaining_attempts: remaining_attempts,
+ remaining_submit_attempts: remaining_submit_attempts,
**extra,
}.compact,
)
@@ -2813,11 +2826,14 @@ def idv_request_letter_visited(
)
end
- # @param [Integer] attempt number of attempts
+ # @param [Integer] captureAttempts number of attempts to capture / upload an image
+ # (previously called "attempt")
# User captured and approved of their selfie
- def idv_sdk_selfie_image_added(attempt:, **extra)
- track_event(:idv_sdk_selfie_image_added, attempt: attempt, **extra)
+ # rubocop:disable Naming/VariableName,Naming/MethodParameterName
+ def idv_sdk_selfie_image_added(captureAttempts:, **extra)
+ track_event(:idv_sdk_selfie_image_added, captureAttempts: captureAttempts, **extra)
end
+ # rubocop:enable Naming/VariableName,Naming/MethodParameterName
# User closed the SDK for taking a selfie without submitting a photo
def idv_sdk_selfie_image_capture_closed_without_photo(**extra)
@@ -2843,7 +2859,8 @@ def idv_sdk_selfie_image_capture_opened(**extra)
track_event(:idv_sdk_selfie_image_capture_opened, **extra)
end
- # @param [Integer] attempt number of attempts
+ # @param [Integer] captureAttempts number of attempts to capture / upload an image
+ # (previously called "attempt")
# @param [Integer] failedImageResubmission
# @param [String] fingerprint fingerprint of the image added
# @param [String] flow_path whether the user is in the hybrid or standard flow
@@ -2855,7 +2872,7 @@ def idv_sdk_selfie_image_capture_opened(**extra)
# User uploaded a selfie using the file picker
# rubocop:disable Naming/VariableName,Naming/MethodParameterName
def idv_selfie_image_file_uploaded(
- attempt:,
+ captureAttempts:,
failedImageResubmission:,
fingerprint:,
flow_path:,
@@ -2868,7 +2885,7 @@ def idv_selfie_image_file_uploaded(
)
track_event(
:idv_selfie_image_file_uploaded,
- attempt: attempt,
+ captureAttempts: captureAttempts,
failedImageResubmission: failedImageResubmission,
fingerprint: fingerprint,
flow_path: flow_path,
@@ -2883,16 +2900,16 @@ def idv_selfie_image_file_uploaded(
# Tracks when the user visits one of the the session error pages.
# @param [String] type
- # @param [Integer,nil] attempts_remaining
+ # @param [Integer,nil] submit_attempts_remaining (previously called "attempts_remaining")
def idv_session_error_visited(
type:,
- attempts_remaining: nil,
+ submit_attempts_remaining: nil,
**extra
)
track_event(
'IdV: session error visited',
type: type,
- attempts_remaining: attempts_remaining,
+ submit_attempts_remaining: submit_attempts_remaining,
**extra,
)
end
@@ -2952,7 +2969,8 @@ def idv_usps_auth_token_refresh_job_started(**extra)
# @param [DateTime] enqueued_at When was this letter enqueued
# @param [Integer] which_letter Sorted by enqueue time, which letter had this code
# @param [Integer] letter_count How many letters did the user enqueue for this profile
- # @param [Integer] attempts Number of attempts to enter a correct code
+ # @param [Integer] submit_attempts Number of attempts to enter a correct code
+ # (previously called "attempts")
# @param [Boolean] pending_in_person_enrollment
# @param [Boolean] fraud_check_failed
# @see Reporting::IdentityVerificationReport#query This event is used by the identity verification
@@ -2965,7 +2983,7 @@ def idv_verify_by_mail_enter_code_submitted(
enqueued_at:,
which_letter:,
letter_count:,
- attempts:,
+ submit_attempts:,
pending_in_person_enrollment:,
fraud_check_failed:,
**extra
@@ -2978,7 +2996,7 @@ def idv_verify_by_mail_enter_code_submitted(
enqueued_at: enqueued_at,
which_letter: which_letter,
letter_count: letter_count,
- attempts: attempts,
+ submit_attempts: submit_attempts,
pending_in_person_enrollment: pending_in_person_enrollment,
fraud_check_failed: fraud_check_failed,
**extra,
@@ -3045,7 +3063,7 @@ def idv_warning_action_triggered(
# @param [String] flow_path
# @param [String] heading
# @param [String] location
- # @param [Integer] remaining_attempts
+ # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts")
# @param [String] subheading
# @param [Boolean] use_alternate_sdk
def idv_warning_shown(
@@ -3055,7 +3073,7 @@ def idv_warning_shown(
flow_path:,
heading:,
location:,
- remaining_attempts:,
+ remaining_submit_attempts:,
subheading:,
use_alternate_sdk:,
**_extra
@@ -3068,7 +3086,7 @@ def idv_warning_shown(
flow_path: flow_path,
heading: heading,
location: location,
- remaining_attempts: remaining_attempts,
+ remaining_submit_attempts: remaining_submit_attempts,
subheading: subheading,
use_alternate_sdk: use_alternate_sdk,
)
diff --git a/app/services/doc_auth/errors.rb b/app/services/doc_auth/errors.rb
index 98068b64158..a8bdf9c514a 100644
--- a/app/services/doc_auth/errors.rb
+++ b/app/services/doc_auth/errors.rb
@@ -29,6 +29,7 @@ module Errors
SELFIE_FAILURE = 'selfie_failure'
SELFIE_NOT_LIVE = 'selfie_not_live'
SELFIE_POOR_QUALITY = 'selfie_poor_quality'
+ SELFIE_NOT_LIVE_POOR_QUALITY_FIELD = 'selfie_not_live_poor_quality'
SEX_CHECK = 'sex_check'
VISIBLE_COLOR_CHECK = 'visible_color_check'
VISIBLE_PHOTO_CHECK = 'visible_photo_check'
@@ -120,8 +121,8 @@ module Errors
# TODO, theses messages need modifying
# Liveness, use general error for now
SELFIE_FAILURE => { long_msg: GENERAL_ERROR, field_msg: FALLBACK_FIELD_LEVEL, hints: false },
- SELFIE_NOT_LIVE => { long_msg: GENERAL_ERROR, field_msg: FALLBACK_FIELD_LEVEL, hints: false },
- SELFIE_POOR_QUALITY => { long_msg: GENERAL_ERROR, field_msg: FALLBACK_FIELD_LEVEL, hints: false },
+ SELFIE_NOT_LIVE => { long_msg: SELFIE_NOT_LIVE, field_msg: SELFIE_NOT_LIVE_POOR_QUALITY_FIELD, hints: false },
+ SELFIE_POOR_QUALITY => { long_msg: SELFIE_POOR_QUALITY, field_msg: SELFIE_NOT_LIVE_POOR_QUALITY_FIELD, hints: false },
}
# rubocop:enable Layout/LineLength
end
diff --git a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb
new file mode 100644
index 00000000000..6fbe3ab1bb7
--- /dev/null
+++ b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb
@@ -0,0 +1,87 @@
+module DocAuth
+ module LexisNexis
+ module DocPiiReader
+ PII_EXCLUDES = %w[
+ Age
+ DocSize
+ DOB_Day
+ DOB_Month
+ DOB_Year
+ ExpirationDate_Day
+ ExpirationDate_Month
+ ExpirationDate_Year
+ FullName
+ Portrait
+ Sex
+ ].freeze
+
+ private
+
+ def read_pii(true_id_product)
+ return {} unless true_id_product&.dig(:IDAUTH_FIELD_DATA).present?
+ pii = {}
+ PII_INCLUDES.each do |true_id_key, idp_key|
+ pii[idp_key] = true_id_product[:IDAUTH_FIELD_DATA][true_id_key]
+ end
+ pii[:state_id_type] = DocAuth::Response::ID_TYPE_SLUGS[pii[:state_id_type]]
+
+ dob = parse_date(
+ year: pii.delete(:dob_year),
+ month: pii.delete(:dob_month),
+ day: pii.delete(:dob_day),
+ )
+ pii[:dob] = dob if dob
+
+ exp_date = parse_date(
+ year: pii.delete(:state_id_expiration_year),
+ month: pii.delete(:state_id_expiration_month),
+ day: pii.delete(:state_id_expiration_day),
+ )
+ pii[:state_id_expiration] = exp_date if exp_date
+
+ issued_date = parse_date(
+ year: pii.delete(:state_id_issued_year),
+ month: pii.delete(:state_id_issued_month),
+ day: pii.delete(:state_id_issued_day),
+ )
+ pii[:state_id_issued] = issued_date if issued_date
+
+ pii
+ end
+
+ PII_INCLUDES = {
+ 'Fields_FirstName' => :first_name,
+ 'Fields_MiddleName' => :middle_name,
+ 'Fields_Surname' => :last_name,
+ 'Fields_AddressLine1' => :address1,
+ 'Fields_AddressLine2' => :address2,
+ 'Fields_City' => :city,
+ 'Fields_State' => :state,
+ 'Fields_PostalCode' => :zipcode,
+ 'Fields_DOB_Year' => :dob_year,
+ 'Fields_DOB_Month' => :dob_month,
+ 'Fields_DOB_Day' => :dob_day,
+ 'Fields_DocumentNumber' => :state_id_number,
+ 'Fields_IssuingStateCode' => :state_id_jurisdiction,
+ 'Fields_xpirationDate_Day' => :state_id_expiration_day, # this is NOT a typo
+ 'Fields_ExpirationDate_Month' => :state_id_expiration_month,
+ 'Fields_ExpirationDate_Year' => :state_id_expiration_year,
+ 'Fields_IssueDate_Day' => :state_id_issued_day,
+ 'Fields_IssueDate_Month' => :state_id_issued_month,
+ 'Fields_IssueDate_Year' => :state_id_issued_year,
+ 'Fields_DocumentClassName' => :state_id_type,
+ 'Fields_CountryCode' => :issuing_country_code,
+ }.freeze
+
+ def parse_date(year:, month:, day:)
+ Date.new(year.to_i, month.to_i, day.to_i).to_s if year.to_i.positive?
+ rescue ArgumentError
+ message = {
+ event: 'Failure to parse TrueID date',
+ }.to_json
+ Rails.logger.info(message)
+ nil
+ end
+ end
+ end
+end
diff --git a/app/services/doc_auth/lexis_nexis/image_metrics_reader.rb b/app/services/doc_auth/lexis_nexis/image_metrics_reader.rb
new file mode 100644
index 00000000000..953d31fb8fb
--- /dev/null
+++ b/app/services/doc_auth/lexis_nexis/image_metrics_reader.rb
@@ -0,0 +1,29 @@
+module DocAuth
+ module LexisNexis
+ module ImageMetricsReader
+ private
+
+ def read_image_metrics(true_id_product)
+ image_metrics = {}
+ return image_metrics unless true_id_product&.dig(:ParameterDetails).present?
+ true_id_product[:ParameterDetails].each do |detail|
+ next unless detail[:Group] == 'IMAGE_METRICS_RESULT'
+
+ inner_val = detail.dig(:Values).collect { |value| value.dig(:Value) }
+ image_metrics[detail[:Name]] = inner_val
+ end
+
+ transform_metrics(image_metrics)
+ end
+
+ def transform_metrics(img_metrics)
+ new_metrics = {}
+ img_metrics['Side']&.each_with_index do |side, i|
+ new_metrics[side.downcase.to_sym] = img_metrics.transform_values { |v| v[i] }
+ end
+
+ new_metrics
+ end
+ end
+ end
+end
diff --git a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb
index d117fa72582..f23cd0b65b9 100644
--- a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb
+++ b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb
@@ -4,57 +4,23 @@ module DocAuth
module LexisNexis
module Responses
class TrueIdResponse < DocAuth::Response
+ include ImageMetricsReader
+ include DocPiiReader
include ClassificationConcern
include SelfieConcern
- PII_EXCLUDES = %w[
- Age
- DocSize
- DOB_Day
- DOB_Month
- DOB_Year
- ExpirationDate_Day
- ExpirationDate_Month
- ExpirationDate_Year
- FullName
- Portrait
- Sex
- ].freeze
-
- PII_INCLUDES = {
- 'Fields_FirstName' => :first_name,
- 'Fields_MiddleName' => :middle_name,
- 'Fields_Surname' => :last_name,
- 'Fields_AddressLine1' => :address1,
- 'Fields_AddressLine2' => :address2,
- 'Fields_City' => :city,
- 'Fields_State' => :state,
- 'Fields_PostalCode' => :zipcode,
- 'Fields_DOB_Year' => :dob_year,
- 'Fields_DOB_Month' => :dob_month,
- 'Fields_DOB_Day' => :dob_day,
- 'Fields_DocumentNumber' => :state_id_number,
- 'Fields_IssuingStateCode' => :state_id_jurisdiction,
- 'Fields_xpirationDate_Day' => :state_id_expiration_day, # this is NOT a typo
- 'Fields_ExpirationDate_Month' => :state_id_expiration_month,
- 'Fields_ExpirationDate_Year' => :state_id_expiration_year,
- 'Fields_IssueDate_Day' => :state_id_issued_day,
- 'Fields_IssueDate_Month' => :state_id_issued_month,
- 'Fields_IssueDate_Year' => :state_id_issued_year,
- 'Fields_DocumentClassName' => :state_id_type,
- 'Fields_CountryCode' => :issuing_country_code,
- }.freeze
+
attr_reader :config, :http_response
def initialize(http_response, config, liveness_checking_enabled = false)
@config = config
@http_response = http_response
@liveness_checking_enabled = liveness_checking_enabled
+ @pii_from_doc = read_pii(true_id_product)
super(
success: successful_result?,
errors: error_messages,
extra: extra_attributes,
- pii_from_doc: pii_from_doc,
- selfie_check_performed: liveness_checking_enabled,
+ pii_from_doc: @pii_from_doc,
)
rescue StandardError => e
NewRelic::Agent.notice_error(e)
@@ -73,7 +39,7 @@ def successful_result?
def error_messages
return {} if successful_result?
- if true_id_product&.dig(:AUTHENTICATION_RESULT).present?
+ if with_authentication_result?
ErrorGenerator.new(config).generate_doc_auth_errors(response_info)
elsif true_id_product.present?
ErrorGenerator.wrapped_general_error(@liveness_checking_enabled)
@@ -83,7 +49,7 @@ def error_messages
end
def extra_attributes
- if true_id_product&.dig(:AUTHENTICATION_RESULT).present?
+ if with_authentication_result?
attrs = response_info.merge(true_id_product[:AUTHENTICATION_RESULT])
attrs.reject! do |k, _v|
PII_EXCLUDES.include?(k) || k.start_with?('Alert_')
@@ -99,38 +65,6 @@ def extra_attributes
basic_logging_info.merge(attrs)
end
- def pii_from_doc
- return {} unless true_id_product&.dig(:IDAUTH_FIELD_DATA).present?
- pii = {}
- PII_INCLUDES.each do |true_id_key, idp_key|
- pii[idp_key] = true_id_product[:IDAUTH_FIELD_DATA][true_id_key]
- end
- pii[:state_id_type] = DocAuth::Response::ID_TYPE_SLUGS[pii[:state_id_type]]
-
- dob = parse_date(
- year: pii.delete(:dob_year),
- month: pii.delete(:dob_month),
- day: pii.delete(:dob_day),
- )
- pii[:dob] = dob if dob
-
- exp_date = parse_date(
- year: pii.delete(:state_id_expiration_year),
- month: pii.delete(:state_id_expiration_month),
- day: pii.delete(:state_id_expiration_day),
- )
- pii[:state_id_expiration] = exp_date if exp_date
-
- issued_date = parse_date(
- year: pii.delete(:state_id_issued_year),
- month: pii.delete(:state_id_issued_month),
- day: pii.delete(:state_id_issued_day),
- )
- pii[:state_id_issued] = issued_date if issued_date
-
- pii
- end
-
def attention_with_barcode?
return false unless doc_auth_result_attention?
@@ -144,10 +78,14 @@ def billed?
end
def doc_auth_success?
- transaction_status_passed? &&
+ # really it's everything else excluding selfie
+ ((transaction_status_passed? &&
true_id_product.present? &&
product_status_passed? &&
doc_auth_result_passed?
+ ) ||
+ attention_with_barcode?
+ ) && id_type_supported?
end
# @return [:success, :fail, :not_processed]
@@ -221,7 +159,6 @@ def response_info
def create_response_info
alerts = parsed_alerts
log_alert_formatter = DocAuth::ProcessedAlertToLogAlertFormatter.new
-
{
transaction_status: transaction_status,
transaction_reason_code: transaction_reason_code,
@@ -232,9 +169,10 @@ def create_response_info
alert_failure_count: alerts[:failed]&.count.to_i,
log_alert_results: log_alert_formatter.log_alerts(alerts),
portrait_match_results: portrait_match_results,
- image_metrics: parse_image_metrics,
+ image_metrics: read_image_metrics(true_id_product),
address_line2_present: !pii_from_doc[:address2].blank?,
classification_info: classification_info,
+ liveness_enabled: @liveness_checking_enabled,
}
end
@@ -256,7 +194,7 @@ def all_passed?
end
def selfie_result
- response_info&.dig(:portrait_match_results, :FaceMatchResult)
+ portrait_match_results&.dig(:FaceMatchResult)
end
def product_status_passed?
@@ -325,7 +263,7 @@ def parsed_alerts
return @new_alerts if defined?(@new_alerts)
@new_alerts = { passed: [], failed: [] }
- return @new_alerts unless true_id_product&.dig(:AUTHENTICATION_RESULT).present?
+ return @new_alerts unless with_authentication_result?
all_alerts = true_id_product[:AUTHENTICATION_RESULT].select do |key|
key.start_with?('Alert_')
end
@@ -364,28 +302,6 @@ def combine_alert_data(all_alerts, alert_name, region_details)
new_alert_data
end
- def parse_image_metrics
- image_metrics = {}
- return image_metrics unless true_id_product&.dig(:ParameterDetails).present?
- true_id_product[:ParameterDetails].each do |detail|
- next unless detail[:Group] == 'IMAGE_METRICS_RESULT'
-
- inner_val = detail.dig(:Values).collect { |value| value.dig(:Value) }
- image_metrics[detail[:Name]] = inner_val
- end
-
- transform_metrics(image_metrics)
- end
-
- def transform_metrics(img_metrics)
- new_metrics = {}
- img_metrics['Side']&.each_with_index do |side, i|
- new_metrics[side.downcase.to_sym] = img_metrics.transform_values { |v| v[i] }
- end
-
- new_metrics
- end
-
# Generate a hash for image references information that can be linked to Alert
# @return A hash with region_id => {:key : 'What region', :side: 'Front|Back'}
def parse_document_region
@@ -433,14 +349,8 @@ def transform_document_region(region_details, image_sides)
end
end
- def parse_date(year:, month:, day:)
- Date.new(year.to_i, month.to_i, day.to_i).to_s if year.to_i.positive?
- rescue ArgumentError
- message = {
- event: 'Failure to parse TrueID date',
- }.to_json
- Rails.logger.info(message)
- nil
+ def with_authentication_result?
+ true_id_product&.dig(:AUTHENTICATION_RESULT).present?
end
end
end
diff --git a/app/services/doc_auth/mock/doc_auth_mock_client.rb b/app/services/doc_auth/mock/doc_auth_mock_client.rb
index ee285dd47e0..82427f1bd49 100644
--- a/app/services/doc_auth/mock/doc_auth_mock_client.rb
+++ b/app/services/doc_auth/mock/doc_auth_mock_client.rb
@@ -75,10 +75,10 @@ def post_images(
back_image_response = post_back_image(image: back_image, instance_id: instance_id)
return back_image_response unless back_image_response.success?
- get_results(instance_id: instance_id, selfie_check_performed: liveness_checking_required)
+ get_results(instance_id: instance_id)
end
- def get_results(instance_id:, selfie_check_performed:)
+ def get_results(instance_id:)
return mocked_response_for_method(__method__) if method_mocked?(__method__)
error_response = http_error_response(self.class.last_uploaded_back_image, 'result')
return error_response if error_response
@@ -90,7 +90,6 @@ def get_results(instance_id:, selfie_check_performed:)
ResultResponse.new(
self.class.last_uploaded_back_image,
- selfie_check_performed,
overriden_config,
)
end
diff --git a/app/services/doc_auth/mock/result_response.rb b/app/services/doc_auth/mock/result_response.rb
index 3e4202ca213..4cb188025cc 100644
--- a/app/services/doc_auth/mock/result_response.rb
+++ b/app/services/doc_auth/mock/result_response.rb
@@ -7,16 +7,14 @@ class ResultResponse < DocAuth::Response
attr_reader :uploaded_file, :config
- def initialize(uploaded_file, selfie_check_performed, config)
+ def initialize(uploaded_file, config)
@uploaded_file = uploaded_file.to_s
- @selfie_check_performed = selfie_check_performed
@config = config
super(
success: success?,
errors: errors,
pii_from_doc: pii_from_doc,
doc_type_supported: id_type_supported?,
- selfie_check_performed: selfie_check_performed,
selfie_live: selfie_live?,
selfie_quality_good: selfie_quality_good?,
extra: {
@@ -63,7 +61,7 @@ def errors
mock_args[:image_metrics] = image_metrics.symbolize_keys if image_metrics.present?
mock_args[:failed] = failed.map!(&:symbolize_keys) unless failed.nil?
mock_args[:passed] = passed.map!(&:symbolize_keys) if passed.present?
- mock_args[:liveness_enabled] = @selfie_check_performed
+ mock_args[:liveness_enabled] = face_match_result ? true : false
mock_args[:classification_info] = classification_info if classification_info.present?
fake_response_info = create_response_info(**mock_args)
ErrorGenerator.new(config).generate_doc_auth_errors(fake_response_info)
@@ -133,7 +131,10 @@ def self.create_network_error_response
end
def doc_auth_success?
- doc_auth_result_from_uploaded_file == 'Passed' || errors.blank?
+ (doc_auth_result_from_uploaded_file == 'Passed' ||
+ errors.blank? ||
+ attention_with_barcode?
+ ) && id_type_supported?
end
def selfie_status
@@ -183,7 +184,7 @@ def doc_auth_result_from_success
def all_doc_capture_values_passing?(doc_auth_result, id_type_supported)
doc_auth_result == 'Passed' &&
id_type_supported &&
- (@selfie_check_performed ? selfie_passed? : true)
+ (selfie_check_performed? ? selfie_passed? : true)
end
def selfie_passed?
@@ -238,7 +239,7 @@ def create_response_info(
image_metrics: merged_image_metrics,
liveness_enabled: liveness_enabled,
classification_info: classification_info,
- portrait_match_results: @selfie_check_performed ? portrait_match_results : nil,
+ portrait_match_results: selfie_check_performed? ? portrait_match_results : nil,
}.compact
end
end
diff --git a/app/services/doc_auth/response.rb b/app/services/doc_auth/response.rb
index d5e7506688c..5c311934a82 100644
--- a/app/services/doc_auth/response.rb
+++ b/app/services/doc_auth/response.rb
@@ -18,7 +18,6 @@ def initialize(
pii_from_doc: {},
attention_with_barcode: false,
doc_type_supported: true,
- selfie_check_performed: false,
selfie_live: true,
selfie_quality_good: true
)
@@ -29,7 +28,6 @@ def initialize(
@pii_from_doc = pii_from_doc
@attention_with_barcode = attention_with_barcode
@doc_type_supported = doc_type_supported
- @selfie_check_performed = selfie_check_performed
@selfie_live = selfie_live
@selfie_quality_good = selfie_quality_good
end
@@ -76,7 +74,7 @@ def to_h
selfie_live: selfie_live?,
selfie_quality_good: selfie_quality_good?,
doc_auth_success: doc_auth_success?,
- selfie_status: selfie_status_from_response(self),
+ selfie_status: selfie_status,
}.merge(extra)
end
@@ -96,12 +94,17 @@ def network_error?
end
def selfie_check_performed?
- @selfie_check_performed
+ false
end
def doc_auth_success?
# to be implemented by concrete subclass
false
end
+
+ def selfie_status
+ # to be implemented by concrete subclass
+ :not_processed
+ end
end
end
diff --git a/app/services/doc_auth/selfie_concern.rb b/app/services/doc_auth/selfie_concern.rb
index 43f29dec079..89e562168fd 100644
--- a/app/services/doc_auth/selfie_concern.rb
+++ b/app/services/doc_auth/selfie_concern.rb
@@ -4,29 +4,35 @@ module SelfieConcern
def selfie_live?
portait_error = get_portrait_error(portrait_match_results)
return true if portait_error.nil? || portait_error.blank?
- return error_is_not_live(portait_error)
+ return !error_is_not_live(portait_error)
end
def selfie_quality_good?
portait_error = get_portrait_error(portrait_match_results)
return true if portait_error.nil? || portait_error.blank?
- return error_is_poor_quality(portait_error)
+ return !error_is_poor_quality(portait_error)
end
def error_is_success(error_message)
- return error_message != ERROR_TEXTS[:success]
+ error_message == ERROR_TEXTS[:success]
end
def error_is_not_live(error_message)
- return error_message != ERROR_TEXTS[:not_live]
+ return error_message == ERROR_TEXTS[:not_live]
end
def error_is_poor_quality(error_message)
- return error_message != ERROR_TEXTS[:poor_quality]
+ error_message == ERROR_TEXTS[:poor_quality]
+ end
+
+ def selfie_check_performed?
+ SELFIE_PERFORMED_STATUSES.include?(selfie_status)
end
private
+ SELFIE_PERFORMED_STATUSES = %i[success fail]
+
ERROR_TEXTS = {
success: 'Successful. Liveness: Live',
not_live: 'Liveness: NotLive',
diff --git a/app/services/doc_auth_router.rb b/app/services/doc_auth_router.rb
index b5f380ce177..80f934c221e 100644
--- a/app/services/doc_auth_router.rb
+++ b/app/services/doc_auth_router.rb
@@ -54,6 +54,13 @@ module DocAuthRouter
# i18n-tasks-use t('doc_auth.errors.alerts.ref_control_number_check')
DocAuth::Errors::REF_CONTROL_NUMBER_CHECK =>
'doc_auth.errors.alerts.ref_control_number_check',
+ # i18n-tasks-use t('doc_auth.errors.alerts.selfie_not_live')
+ DocAuth::Errors::SELFIE_NOT_LIVE => 'doc_auth.errors.alerts.selfie_not_live',
+ # i18n-tasks-use t('doc_auth.errors.alerts.selfie_poor_quality')
+ DocAuth::Errors::SELFIE_POOR_QUALITY => 'doc_auth.errors.alerts.selfie_poor_quality',
+ # i18n-tasks-use t('doc_auth.errors.alerts.selfie_not_live_poor_quality')
+ DocAuth::Errors::SELFIE_NOT_LIVE_POOR_QUALITY_FIELD =>
+ 'doc_auth.errors.alerts.selfie_not_live_poor_quality',
# i18n-tasks-use t('doc_auth.errors.alerts.sex_check')
DocAuth::Errors::SEX_CHECK => 'doc_auth.errors.alerts.sex_check',
# i18n-tasks-use t('doc_auth.errors.alerts.visible_color_check')
diff --git a/app/services/document_capture_session_result.rb b/app/services/document_capture_session_result.rb
index 3a8c527afee..c1bfac2a57c 100644
--- a/app/services/document_capture_session_result.rb
+++ b/app/services/document_capture_session_result.rb
@@ -8,14 +8,18 @@
:attention_with_barcode,
:failed_front_image_fingerprints,
:failed_back_image_fingerprints,
+ :failed_selfie_image_fingerprints,
:captured_at,
:selfie_check_performed,
:doc_auth_success, :selfie_status, :selfie_success,
keyword_init: true,
allowed_members: [:id, :success, :attention_with_barcode, :failed_front_image_fingerprints,
- :failed_back_image_fingerprints, :captured_at, :selfie_check_performed,
- :doc_auth_success, :selfie_status, :selfie_success]
+ :failed_back_image_fingerprints, :failed_selfie_image_fingerprints,
+ :captured_at, :selfie_check_performed, :doc_auth_success, :selfie_status,
+ :selfie_success]
) do
+ include DocAuth::SelfieConcern
+
def self.redis_key_prefix
'dcs:result'
end
@@ -28,11 +32,13 @@ def selfie_status
alias_method :attention_with_barcode?, :attention_with_barcode
alias_method :pii_from_doc, :pii
- %w[front back].each do |side|
+ %w[front back selfie].each do |side|
define_method(:"add_failed_#{side}_image!") do |fingerprint|
member_name = "failed_#{side}_image_fingerprints"
self[member_name] ||= []
- self[member_name] << fingerprint
+ if fingerprint && !self[member_name].include?(fingerprint)
+ self[member_name] << fingerprint
+ end
end
define_method(:"failed_#{side}_image?") do |fingerprint|
diff --git a/app/services/reporting/account_reuse_report.rb b/app/services/reporting/account_reuse_report.rb
index 4bb534270c5..f8c08b7ad65 100644
--- a/app/services/reporting/account_reuse_report.rb
+++ b/app/services/reporting/account_reuse_report.rb
@@ -65,15 +65,20 @@ def initialize(
def update_details(
num_entities: nil, entity_type: nil,
- num_idv_users: nil, num_all_users: nil
+ num_all_users: nil, all_percent: nil,
+ num_idv_users: nil, idv_percent: nil
)
self.num_entities = num_entities if !num_entities.nil?
self.entity_type = entity_type if !entity_type.nil?
+ self.num_all_users = num_all_users if !num_all_users.nil?
+
+ self.all_percent = all_percent if !all_percent.nil?
+
self.num_idv_users = num_idv_users if !num_idv_users.nil?
- self.num_all_users = num_all_users if !num_all_users.nil?
+ self.idv_percent = idv_percent if !idv_percent.nil?
self
end
@@ -173,6 +178,23 @@ def update_from_results(results:, total_registered:, total_proofed:)
end
end
end
+
+ results.each_with_index do |details_section, section_index|
+ details_section.select { |details| details.num_entities >= 10 }.
+ reduce do |summary_row, captured_row|
+ # Delete any rows after the first captured_row (which becomes the summary_row)
+ details_section.delete(captured_row) if captured_row != summary_row
+ summary_row.update_details(
+ num_entities: "10-#{captured_row.num_entities}",
+ entity_type: summary_row.entity_type,
+ num_all_users: summary_row.num_all_users + captured_row.num_all_users,
+ all_percent: summary_row.all_percent + captured_row.all_percent,
+ num_idv_users: summary_row.num_idv_users + captured_row.num_idv_users,
+ idv_percent: summary_row.idv_percent + captured_row.idv_percent,
+ )
+ end
+ end
+
self.details_section = results
self
diff --git a/app/services/reset_user_password.rb b/app/services/reset_user_password.rb
index d4152b4836c..400df73db3e 100644
--- a/app/services/reset_user_password.rb
+++ b/app/services/reset_user_password.rb
@@ -32,7 +32,7 @@ def log_event
end
def notify_user
- user.email_addresses.each do |email_address|
+ user.confirmed_email_addresses.each do |email_address|
UserMailer.with(user: user, email_address: email_address).please_reset_password.
deliver_now_or_later
end
diff --git a/app/services/service_provider_request_proxy.rb b/app/services/service_provider_request_proxy.rb
index d39e615c085..f99c21b5947 100644
--- a/app/services/service_provider_request_proxy.rb
+++ b/app/services/service_provider_request_proxy.rb
@@ -34,7 +34,7 @@ def self.find_or_create_by(uuid:)
spr = ServiceProviderRequest.new(
uuid: uuid, issuer: nil, url: nil, ial: nil,
aal: nil, requested_attributes: nil,
- biometric_comparison_required: false
+ biometric_comparison_required: false, acr_values: nil, vtr: nil
)
yield(spr)
create(
@@ -45,13 +45,22 @@ def self.find_or_create_by(uuid:)
aal: spr.aal,
requested_attributes: spr.requested_attributes,
biometric_comparison_required: spr.biometric_comparison_required,
+ acr_values: spr.acr_values,
+ vtr: spr.vtr,
)
end
def self.create(hash)
uuid = hash[:uuid]
obj = hash.slice(
- :issuer, :url, :ial, :aal, :requested_attributes, :biometric_comparison_required
+ :issuer,
+ :url,
+ :ial,
+ :aal,
+ :requested_attributes,
+ :biometric_comparison_required,
+ :acr_values,
+ :vtr,
)
write(obj, uuid)
hash_to_spr(obj, uuid)
diff --git a/app/views/account_reset/pending/confirm.html.erb b/app/views/account_reset/pending/confirm.html.erb
index b94afed79bc..3c94e90c97c 100644
--- a/app/views/account_reset/pending/confirm.html.erb
+++ b/app/views/account_reset/pending/confirm.html.erb
@@ -1,6 +1,6 @@
<% self.title = t('account_reset.cancel_request.title') %>
-<%= t('account_reset.pending.confirm') %>
+<%= t('account_reset.pending.confirm', interval: @account_reset_deletion_period_interval) %>
<%= button_to(
account_reset_pending_cancel_path,
diff --git a/app/views/account_reset/pending/show.html.erb b/app/views/account_reset/pending/show.html.erb
index 14e2f17e86d..ceb730fe1fc 100644
--- a/app/views/account_reset/pending/show.html.erb
+++ b/app/views/account_reset/pending/show.html.erb
@@ -5,6 +5,7 @@
<%= t(
'account_reset.pending.wait_html',
+ hours: @pending_presenter.account_reset_deletion_period_hours,
interval: @pending_presenter.time_remaining_until_granted,
) %>
diff --git a/app/views/account_reset/request/show.html.erb b/app/views/account_reset/request/show.html.erb
index 58f60653804..9abfd4eab7f 100644
--- a/app/views/account_reset/request/show.html.erb
+++ b/app/views/account_reset/request/show.html.erb
@@ -8,7 +8,7 @@
<%= t('account_reset.request.delete_account') %>
-<% t('account_reset.request.delete_account_info').each do |info_p| %>
+<% t('account_reset.request.delete_account_info', interval: @account_reset_deletion_period_interval).each do |info_p| %>
<%= info_p %>
<% end %>
diff --git a/app/views/idv/phone_errors/warning.html.erb b/app/views/idv/phone_errors/warning.html.erb
index 58176958201..1d83f972eed 100644
--- a/app/views/idv/phone_errors/warning.html.erb
+++ b/app/views/idv/phone_errors/warning.html.erb
@@ -28,7 +28,7 @@
- <%= t('idv.failure.phone.warning.attempts_html', count: @remaining_attempts) %>
+ <%= t('idv.failure.phone.warning.attempts_html', count: @remaining_submit_attempts) %>
diff --git a/app/views/idv/session_errors/warning.html.erb b/app/views/idv/session_errors/warning.html.erb
index b7b32eead64..97f32940ff1 100644
--- a/app/views/idv/session_errors/warning.html.erb
+++ b/app/views/idv/session_errors/warning.html.erb
@@ -13,7 +13,7 @@
<% c.with_header { t('idv.warning.sessions.heading') } %>
<%= t('idv.failure.sessions.warning') %>
-
<%= t('idv.warning.attempts_html', count: @remaining_attempts) %>
+
<%= t('idv.warning.attempts_html', count: @remaining_submit_attempts) %>
<% c.with_action_button(
action: ->(**tag_options, &block) { link_to(@try_again_path, **tag_options, &block) },
diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb
index 571d6cc8ef5..60422ebd37b 100644
--- a/app/views/layouts/base.html.erb
+++ b/app/views/layouts/base.html.erb
@@ -106,8 +106,8 @@
{ type: 'application/json', data: { config: '' } },
false,
) %>
- <%= javascript_packs_tag_once('application', prepend: true) %>
- <%= javascript_packs_tag_once('track-errors') if BrowserSupport.supported?(request.user_agent) %>
+ <%= javascript_packs_tag_once('application') %>
+ <%= javascript_packs_tag_once('track-errors', async: true) if BrowserSupport.supported?(request.user_agent) %>
<%= render_javascript_pack_once_tags %>
<%= render 'shared/dap_analytics' if IdentityConfig.store.participate_in_dap && !session_with_trust? %>
diff --git a/app/views/layouts/component_preview.html.erb b/app/views/layouts/component_preview.html.erb
index 2e70f8fea54..5a73c75a763 100644
--- a/app/views/layouts/component_preview.html.erb
+++ b/app/views/layouts/component_preview.html.erb
@@ -16,7 +16,7 @@
<% else %>
<%= yield %>
<% end %>
- <%= javascript_packs_tag_once('application', prepend: true) %>
+ <%= javascript_packs_tag_once('application') %>
<%= render_javascript_pack_once_tags %>