From 708bd6b1abad0f187ddfe9980ad97c752e284bc8 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Thu, 25 Apr 2024 12:28:34 -0400 Subject: [PATCH 01/14] Return a `Pii::StateId` object from `pii_from_doc` on the mock proofer (#10488) In #10478 I modified the mock proofer to always return the full set of keys that the TrueID client returns from the `#pii_from_doc` method. This commit continues on the work by introducing `Pii:StateId` to hold the data that is returned from `#pii_from_doc`. It is an immutable struct that holds the data and provides assurances about its shape by ensuring that data is present for every key. In follow-up commits this data structure will be returned from the TrueID client and used to represent the PII from the document in `Idv::Session`. [skip changelog] --- app/forms/idv/api_image_upload_form.rb | 8 +- app/models/document_capture_session.rb | 2 +- .../image_upload_response_presenter.rb | 2 +- app/services/doc_auth/mock/result_response.rb | 10 +- app/services/pii/state_id.rb | 23 +++ spec/forms/idv/api_image_upload_form_spec.rb | 6 +- .../mock/doc_auth_mock_client_spec.rb | 96 ++++++----- .../doc_auth/mock/result_response_spec.rb | 154 +++++++++--------- 8 files changed, 169 insertions(+), 132 deletions(-) create mode 100644 app/services/pii/state_id.rb diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index e47d367005f..f09d830db3f 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -95,8 +95,8 @@ def post_images_to_client end response.extra.merge!(extra_attributes) - response.extra[:state] = response.pii_from_doc[:state] - response.extra[:state_id_type] = response.pii_from_doc[:state_id_type] + response.extra[:state] = response.pii_from_doc.to_h[:state] + response.extra[:state_id_type] = response.pii_from_doc.to_h[:state_id_type] update_analytics( client_response: response, @@ -119,7 +119,7 @@ def selfie_image_bytes def validate_pii_from_doc(client_response) response = Idv::DocPiiForm.new( - pii: client_response.pii_from_doc, + pii: client_response.pii_from_doc.to_h, attention_with_barcode: client_response.attention_with_barcode?, ).submit response.extra.merge!(extra_attributes) @@ -452,7 +452,7 @@ def rate_limited? end def track_event(response) - pii_from_doc = response.pii_from_doc || {} + pii_from_doc = response.pii_from_doc.to_h || {} stored_image_result = store_encrypted_images_if_required irs_attempts_api_tracker.idv_document_upload_submitted( diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb index 7e6ddec3c07..0963509df7d 100644 --- a/app/models/document_capture_session.rb +++ b/app/models/document_capture_session.rb @@ -16,7 +16,7 @@ def store_result_from_response(doc_auth_response) id: generate_result_id, ) session_result.success = doc_auth_response.success? - session_result.pii = doc_auth_response.pii_from_doc + session_result.pii = doc_auth_response.pii_from_doc.to_h session_result.captured_at = Time.zone.now session_result.attention_with_barcode = doc_auth_response.attention_with_barcode? session_result.doc_auth_success = doc_auth_response.doc_auth_success? diff --git a/app/presenters/image_upload_response_presenter.rb b/app/presenters/image_upload_response_presenter.rb index 3720c0a34c4..5a33c7f4288 100644 --- a/app/presenters/image_upload_response_presenter.rb +++ b/app/presenters/image_upload_response_presenter.rb @@ -88,7 +88,7 @@ def attention_with_barcode? def ocr_pii return unless success? return unless attention_with_barcode? && @form_response.respond_to?(:pii_from_doc) - @form_response.pii_from_doc&.slice(:first_name, :last_name, :dob) + @form_response.pii_from_doc.to_h.slice(:first_name, :last_name, :dob) end def doc_type_supported? diff --git a/app/services/doc_auth/mock/result_response.rb b/app/services/doc_auth/mock/result_response.rb index 10b10d5cfe0..3d7784016a6 100644 --- a/app/services/doc_auth/mock/result_response.rb +++ b/app/services/doc_auth/mock/result_response.rb @@ -82,7 +82,7 @@ def pii_from_doc if parsed_data_from_uploaded_file.present? parsed_pii_from_doc else - Idp::Constants::MOCK_IDV_APPLICANT + Pii::StateId.new(**Idp::Constants::MOCK_IDV_APPLICANT) end end @@ -132,11 +132,11 @@ def parsed_alerts def parsed_pii_from_doc if parsed_data_from_uploaded_file.has_key?('document') - Idp::Constants::MOCK_IDV_APPLICANT.merge( - parsed_data_from_uploaded_file['document'].symbolize_keys, + Pii::StateId.new( + **Idp::Constants::MOCK_IDV_APPLICANT.merge( + parsed_data_from_uploaded_file['document'].symbolize_keys, + ).slice(*Pii::StateId.members), ) - else - {} end end diff --git a/app/services/pii/state_id.rb b/app/services/pii/state_id.rb new file mode 100644 index 00000000000..713766d68ee --- /dev/null +++ b/app/services/pii/state_id.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# rubocop:disable Style/MutableConstant +module Pii + StateId = RedactedData.define( + :first_name, + :last_name, + :middle_name, + :address1, + :address2, + :city, + :state, + :dob, + :state_id_expiration, + :state_id_issued, + :state_id_jurisdiction, + :state_id_number, + :state_id_type, + :zipcode, + :issuing_country_code, + ) +end +# rubocop:enable Style/MutableConstant diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 99ecec7ee91..c32270c6e60 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -270,7 +270,7 @@ expect(response.selfie_status).to eq(:not_processed) expect(response.errors).to eq({}) expect(response.attention_with_barcode?).to eq(false) - expect(response.pii_from_doc).to eq(Idp::Constants::MOCK_IDV_APPLICANT) + expect(response.pii_from_doc).to eq(Pii::StateId.new(**Idp::Constants::MOCK_IDV_APPLICANT)) end context 'when liveness check is required' do @@ -422,7 +422,7 @@ }, ) expect(response.attention_with_barcode?).to eq(false) - expect(response.pii_from_doc).to eq({}) + expect(response.pii_from_doc.to_h).to eq({}) end end @@ -453,7 +453,7 @@ expect(response.success?).to eq(true) expect(response.errors).to eq({}) expect(response.attention_with_barcode?).to eq(false) - expect(response.pii_from_doc).to eq(Idp::Constants::MOCK_IDV_APPLICANT) + expect(response.pii_from_doc).to eq(Pii::StateId.new(**Idp::Constants::MOCK_IDV_APPLICANT)) end end diff --git a/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb b/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb index 14fe76ee8f8..e4ec9c1487c 100644 --- a/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb +++ b/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb @@ -24,21 +24,23 @@ expect(get_results_response.success?).to eq(true) expect(get_results_response.pii_from_doc).to eq( - first_name: 'FAKEY', - middle_name: nil, - last_name: 'MCFAKERSON', - address1: '1 FAKE RD', - address2: nil, - city: 'GREAT FALLS', - state: 'MT', - zipcode: '59010', - dob: '1938-10-06', - state_id_number: '1111111111111', - state_id_jurisdiction: 'ND', - state_id_type: 'drivers_license', - state_id_expiration: '2099-12-31', - state_id_issued: '2019-12-31', - issuing_country_code: 'US', + Pii::StateId.new( + first_name: 'FAKEY', + middle_name: nil, + last_name: 'MCFAKERSON', + address1: '1 FAKE RD', + address2: nil, + city: 'GREAT FALLS', + state: 'MT', + zipcode: '59010', + dob: '1938-10-06', + state_id_number: '1111111111111', + state_id_jurisdiction: 'ND', + state_id_type: 'drivers_license', + state_id_expiration: '2099-12-31', + state_id_issued: '2019-12-31', + issuing_country_code: 'US', + ), ) end @@ -78,21 +80,23 @@ ) expect(get_results_response.pii_from_doc).to eq( - first_name: 'Susan', - middle_name: 'Q', - last_name: 'Smith', - address1: '1 Microsoft Way', - address2: 'Apt 3', - city: 'Bayside', - state: 'NY', - zipcode: '11364', - dob: '1938-10-06', - state_id_number: '111111111', - state_id_jurisdiction: 'ND', - state_id_type: 'drivers_license', - state_id_expiration: '2089-12-31', - state_id_issued: '2009-12-31', - issuing_country_code: 'CA', + Pii::StateId.new( + first_name: 'Susan', + middle_name: 'Q', + last_name: 'Smith', + address1: '1 Microsoft Way', + address2: 'Apt 3', + city: 'Bayside', + state: 'NY', + zipcode: '11364', + dob: '1938-10-06', + state_id_number: '111111111', + state_id_jurisdiction: 'ND', + state_id_type: 'drivers_license', + state_id_expiration: '2089-12-31', + state_id_issued: '2009-12-31', + issuing_country_code: 'CA', + ), ) expect(get_results_response.attention_with_barcode?).to eq(false) end @@ -119,21 +123,23 @@ ) expect(get_results_response.pii_from_doc).to eq( - first_name: 'Susan', - middle_name: nil, - last_name: 'MCFAKERSON', - address1: '1 FAKE RD', - address2: nil, - city: 'GREAT FALLS', - state: 'MT', - zipcode: '59010', - dob: '1938-10-06', - state_id_number: '1111111111111', - state_id_jurisdiction: 'ND', - state_id_type: 'drivers_license', - state_id_expiration: '2099-12-31', - state_id_issued: '2019-12-31', - issuing_country_code: 'US', + Pii::StateId.new( + first_name: 'Susan', + middle_name: nil, + last_name: 'MCFAKERSON', + address1: '1 FAKE RD', + address2: nil, + city: 'GREAT FALLS', + state: 'MT', + zipcode: '59010', + dob: '1938-10-06', + state_id_number: '1111111111111', + state_id_jurisdiction: 'ND', + state_id_type: 'drivers_license', + state_id_expiration: '2099-12-31', + state_id_issued: '2019-12-31', + issuing_country_code: 'US', + ), ) expect(get_results_response.attention_with_barcode?).to eq(false) end diff --git a/spec/services/doc_auth/mock/result_response_spec.rb b/spec/services/doc_auth/mock/result_response_spec.rb index d1a93bec518..0e247fd97b0 100644 --- a/spec/services/doc_auth/mock/result_response_spec.rb +++ b/spec/services/doc_auth/mock/result_response_spec.rb @@ -20,7 +20,7 @@ expect(response.success?).to eq(true) expect(response.errors).to eq({}) expect(response.exception).to eq(nil) - expect(response.pii_from_doc). + expect(response.pii_from_doc.to_h). to eq(Idp::Constants::MOCK_IDV_APPLICANT) expect(response.attention_with_barcode?).to eq(false) expect(response.selfie_status).to eq(:success) @@ -54,21 +54,23 @@ expect(response.errors).to eq({}) expect(response.exception).to eq(nil) expect(response.pii_from_doc).to eq( - first_name: 'Susan', - middle_name: 'Q', - last_name: 'Smith', - address1: '1 Microsoft Way', - address2: 'Apt 3', - city: 'Bayside', - state: 'NY', - zipcode: '11364', - dob: '1938-10-06', - state_id_number: '111111111', - state_id_jurisdiction: 'ND', - state_id_type: 'drivers_license', - state_id_expiration: '2089-12-31', - state_id_issued: '2009-12-31', - issuing_country_code: 'CA', + Pii::StateId.new( + first_name: 'Susan', + middle_name: 'Q', + last_name: 'Smith', + address1: '1 Microsoft Way', + address2: 'Apt 3', + city: 'Bayside', + state: 'NY', + zipcode: '11364', + dob: '1938-10-06', + state_id_number: '111111111', + state_id_jurisdiction: 'ND', + state_id_type: 'drivers_license', + state_id_expiration: '2089-12-31', + state_id_issued: '2009-12-31', + issuing_country_code: 'CA', + ), ) expect(response.attention_with_barcode?).to eq(false) end @@ -97,7 +99,7 @@ expect(response.success?).to eq(true) expect(response.errors).to eq({}) expect(response.exception).to eq(nil) - expect(response.pii_from_doc).to include(dob: '1938-10-06') + expect(response.pii_from_doc.dob).to eq('1938-10-06') expect(response.attention_with_barcode?).to eq(false) end end @@ -121,7 +123,7 @@ hints: true, ) expect(response.exception).to eq(nil) - expect(response.pii_from_doc).to eq({}) + expect(response.pii_from_doc).to eq(nil) expect(response.attention_with_barcode?).to eq(false) end end @@ -145,7 +147,8 @@ general: ['barcode_read_check'], hints: true ) expect(response.exception).to eq(nil) - expect(response.pii_from_doc).to include(first_name: 'Susan', last_name: nil) + expect(response.pii_from_doc.first_name).to eq('Susan') + expect(response.pii_from_doc.last_name).to eq(nil) expect(response.attention_with_barcode?).to eq(true) end end @@ -183,7 +186,7 @@ hints: false, ) expect(response.exception).to eq(nil) - expect(response.pii_from_doc).to eq({}) + expect(response.pii_from_doc).to eq(nil) expect(response.attention_with_barcode?).to eq(false) end end @@ -199,7 +202,7 @@ expect(response.success?).to eq(true) expect(response.errors).to eq({}) expect(response.exception).to eq(nil) - expect(response.pii_from_doc). + expect(response.pii_from_doc.to_h). to eq(Idp::Constants::MOCK_IDV_APPLICANT) expect(response.attention_with_barcode?).to eq(false) end @@ -216,7 +219,7 @@ expect(response.success?).to eq(false) expect(response.errors).to eq(general: ['parsed URI, but scheme was https (expected data)']) expect(response.exception).to eq(nil) - expect(response.pii_from_doc).to eq({}) + expect(response.pii_from_doc).to eq(nil) expect(response.attention_with_barcode?).to eq(false) end end @@ -232,7 +235,7 @@ expect(response.success?).to eq(false) expect(response.errors).to eq(general: ['YAML data should have been a hash, got String']) expect(response.exception).to eq(nil) - expect(response.pii_from_doc).to eq({}) + expect(response.pii_from_doc).to eq(nil) expect(response.attention_with_barcode?).to eq(false) end end @@ -251,7 +254,7 @@ expect(response.success?).to eq(false) expect(response.errors).to eq(general: ['invalid YAML file']) expect(response.exception).to eq(nil) - expect(response.pii_from_doc).to eq({}) + expect(response.pii_from_doc).to eq(nil) expect(response.attention_with_barcode?).to eq(false) end end @@ -295,22 +298,23 @@ expect(response.errors).to eq({}) expect(response.exception).to eq(nil) expect(response.pii_from_doc).to eq( - first_name: 'Susan', - middle_name: 'Q', - last_name: 'Smith', - phone: '+1 314-555-1212', - address1: '1 Microsoft Way', - address2: 'Apt 3', - city: 'Bayside', - state: 'NY', - state_id_jurisdiction: 'NY', - state_id_number: '123456789', - zipcode: '11364', - dob: '1938-10-06', - state_id_type: 'drivers_license', - state_id_expiration: '2089-12-31', - state_id_issued: '2009-12-31', - issuing_country_code: 'CA', + Pii::StateId.new( + first_name: 'Susan', + middle_name: 'Q', + last_name: 'Smith', + address1: '1 Microsoft Way', + address2: 'Apt 3', + city: 'Bayside', + state: 'NY', + state_id_jurisdiction: 'NY', + state_id_number: '123456789', + zipcode: '11364', + dob: '1938-10-06', + state_id_type: 'drivers_license', + state_id_expiration: '2089-12-31', + state_id_issued: '2009-12-31', + issuing_country_code: 'CA', + ), ) expect(response.attention_with_barcode?).to eq(false) expect(response.extra).to eq( @@ -347,21 +351,23 @@ it 'returns default values for the missing fields' do expect(response.success?).to eq(true) expect(response.pii_from_doc).to eq( - first_name: 'Susan', - middle_name: nil, - last_name: 'MCFAKERSON', - address1: '1 FAKE RD', - address2: nil, - city: 'GREAT FALLS', - state: 'MT', - zipcode: '59010', - dob: '1938-10-06', - state_id_number: '1111111111111', - state_id_jurisdiction: 'ND', - state_id_type: 'drivers_license', - state_id_expiration: '2099-12-31', - state_id_issued: '2019-12-31', - issuing_country_code: 'US', + Pii::StateId.new( + first_name: 'Susan', + middle_name: nil, + last_name: 'MCFAKERSON', + address1: '1 FAKE RD', + address2: nil, + city: 'GREAT FALLS', + state: 'MT', + zipcode: '59010', + dob: '1938-10-06', + state_id_number: '1111111111111', + state_id_jurisdiction: 'ND', + state_id_type: 'drivers_license', + state_id_expiration: '2099-12-31', + state_id_issued: '2019-12-31', + issuing_country_code: 'US', + ), ) end end @@ -383,7 +389,7 @@ hints: false, ) expect(response.exception).to eq(nil) - expect(response.pii_from_doc).to eq({}) + expect(response.pii_from_doc).to eq(nil) expect(response.attention_with_barcode?).to eq(false) expect(response.extra).to eq( doc_auth_result: DocAuth::LexisNexis::ResultCodes::CAUTION.name, @@ -410,7 +416,7 @@ hints: true, ) expect(response.exception).to eq(nil) - expect(response.pii_from_doc).to eq({}) + expect(response.pii_from_doc).to eq(nil) expect(response.attention_with_barcode?).to eq(false) expect(response.extra).to eq( doc_auth_result: DocAuth::LexisNexis::ResultCodes::FAILED.name, @@ -449,21 +455,23 @@ expect(response.errors).to eq({}) expect(response.exception).to eq(nil) expect(response.pii_from_doc).to eq( - first_name: 'Susan', - middle_name: 'Q', - last_name: 'Smith', - address1: '1 Microsoft Way', - address2: 'Apt 3', - city: 'Bayside', - state: 'NY', - zipcode: '11364', - dob: '1938-10-06', - state_id_number: '111111111', - state_id_jurisdiction: 'ND', - state_id_type: 'drivers_license', - state_id_expiration: '2089-12-31', - state_id_issued: '2009-12-31', - issuing_country_code: 'CA', + Pii::StateId.new( + first_name: 'Susan', + middle_name: 'Q', + last_name: 'Smith', + address1: '1 Microsoft Way', + address2: 'Apt 3', + city: 'Bayside', + state: 'NY', + zipcode: '11364', + dob: '1938-10-06', + state_id_number: '111111111', + state_id_jurisdiction: 'ND', + state_id_type: 'drivers_license', + state_id_expiration: '2089-12-31', + state_id_issued: '2009-12-31', + issuing_country_code: 'CA', + ), ) expect(response.attention_with_barcode?).to eq(false) expect(response.extra).to eq( @@ -649,7 +657,7 @@ YAML end it 'successfully extracts PII' do - expect(response.pii_from_doc.empty?).to eq(false) + expect(response.pii_from_doc).to_not be_blank end end context 'with a yaml file that includes classification info' do From f7fbf59995b4eba39313505b367aa2cbee12eea6 Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Thu, 25 Apr 2024 13:21:43 -0400 Subject: [PATCH 02/14] Log event for new device alert job emails sent (#10506) * Log event for new device alert job emails sent changelog: Internal, Logging, Log event for new device alert job emails sent * Fix YARDoc --- app/services/analytics_events.rb | 6 ++++++ app/services/create_new_device_alert.rb | 6 ++++++ spec/services/create_new_device_alert_spec.rb | 12 ++++++++++++ 3 files changed, 24 insertions(+) diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index dd213d2691e..0f31d186ccf 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -334,6 +334,12 @@ def contact_redirect(redirect_url:, step: nil, location: nil, flow: nil, **extra ) end + # New device sign-in alerts sent after expired notification timeframe + # @param [Integer] count Number of emails sent + def create_new_device_alert_job_emails_sent(count:, **extra) + track_event(:create_new_device_alert_job_emails_sent, count:, **extra) + end + # @param [String] message the warning # Logged when there is a non-user-facing error in the doc auth process, such as an unrecognized # field from a vendor diff --git a/app/services/create_new_device_alert.rb b/app/services/create_new_device_alert.rb index f10df5aa20f..7cda3582010 100644 --- a/app/services/create_new_device_alert.rb +++ b/app/services/create_new_device_alert.rb @@ -12,11 +12,17 @@ def perform(now) emails_sent += 1 if expire_sign_in_notification_timeframe_and_send_alert(user) end + analytics.create_new_device_alert_job_emails_sent(count: emails_sent) + emails_sent end private + def analytics + @analytics ||= Analytics.new(user: AnonymousUser.new, request: nil, sp: nil, session: {}) + end + def sql_query_for_users_with_new_device <<~SQL sign_in_new_device_at IS NOT NULL AND diff --git a/spec/services/create_new_device_alert_spec.rb b/spec/services/create_new_device_alert_spec.rb index 1100524c293..3517894a421 100644 --- a/spec/services/create_new_device_alert_spec.rb +++ b/spec/services/create_new_device_alert_spec.rb @@ -3,10 +3,12 @@ RSpec.describe CreateNewDeviceAlert do let(:user) { create(:user) } let(:now) { Time.zone.now } + before do user.update! sign_in_new_device_at: now - 1 - IdentityConfig.store.new_device_alert_delay_in_minutes.minutes end + describe '#perform' do it 'sends an email for matching user' do emails_sent = CreateNewDeviceAlert.new.perform(now) @@ -25,5 +27,15 @@ emails_sent = CreateNewDeviceAlert.new.perform(now) expect(emails_sent).to eq(0) end + + it 'logs analytics with number of emails sent' do + analytics = FakeAnalytics.new + alert = CreateNewDeviceAlert.new + allow(alert).to receive(:analytics).and_return(analytics) + + alert.perform(now) + + expect(analytics).to have_logged_event(:create_new_device_alert_job_emails_sent, count: 1) + end end end From 947bbbfe2e9bd0a3e488a2a40a4d6d0120a219c6 Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Thu, 25 Apr 2024 12:46:46 -0500 Subject: [PATCH 03/14] Increase strictness and improve ability to keep internationalization tests up-to-date (#10508) * Increase strictness and improve ability to keep internationalization tests up-to-date changelog: Internal, Internationalization, Increase strictness and improve ability to keep internationalization tests up-to-date * track usage * remove unnecessary exceptions in untranslated key list * Update spec/i18n_spec.rb Co-authored-by: Zach Margolis --------- Co-authored-by: Zach Margolis --- spec/i18n_spec.rb | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index cd99f9d23de..95bc616813b 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -6,7 +6,7 @@ # List of keys allowed to contain different interpolation arguments across locales ALLOWED_INTERPOLATION_MISMATCH_KEYS = [ 'time.formats.event_timestamp_js', -].freeze +].sort.freeze # A set of patterns which are expected to only occur within specific locales. This is an imperfect # solution based on current content, intended to help prevent accidents when adding new translated @@ -34,22 +34,14 @@ class BaseTask { key: /^countries/ }, # Some countries have the same name across languages { key: 'datetime.dotiw.minutes.one' }, # "minute is minute" in French and English { key: 'datetime.dotiw.minutes.other' }, # "minute is minute" in French and English - { key: 'doc_auth.headings.photo', locales: %i[fr] }, # "Photo" is "Photo" in French - { key: 'doc_auth.headings.selfie', locales: %i[fr] }, # "Photo" is "Photo" in French { key: /^i18n\.locale\./ }, # Show locale options translated as that language - { key: /^i18n\.transliterate\./ }, # Approximate non-ASCII characters in ASCII { key: 'links.contact', locales: %i[fr] }, # "Contact" is "Contact" in French { key: 'mailer.logo' }, # "logo is logo" in English, French and Spanish { key: 'saml_idp.auth.error.title', locales: %i[es] }, # "Error" is "Error" in Spanish { key: 'simple_form.no', locales: %i[es] }, # "No" is "No" in Spanish - { key: 'simple_form.required.html' }, # No text content - { key: 'simple_form.required.mark' }, # No text content { key: 'time.am' }, # "AM" is "AM" in French and Spanish { key: 'time.formats.sms_date' }, # for us date format { key: 'time.pm' }, # "PM" is "PM" in French and Spanish - { key: 'datetime.dotiw.minutes.one' }, # "minute is minute" in French and English - { key: 'datetime.dotiw.minutes.other' }, # "minute is minute" in French and English - { key: 'mailer.logo' }, # "logo is logo" in English, French and Spanish { key: 'datetime.dotiw.words_connector' }, # " , " is only punctuation and not translated ].freeze @@ -68,15 +60,21 @@ def untranslated_key?(key, base_locale_value) node = data[current_locale].first.children[key] next unless node&.value&.is_a?(String) next if node.value.empty? - next if allowed_untranslated_key?(current_locale, key) - node.value == base_locale_value + next unless node.value == base_locale_value + true unless allowed_untranslated_key?(current_locale, key) end end def allowed_untranslated_key?(locale, key) ALLOWED_UNTRANSLATED_KEYS.any? do |entry| - next unless key&.match?(Regexp.new(entry[:key])) - !entry.key?(:locales) || entry[:locales].include?(locale.to_sym) + next if entry[:key].is_a?(Regexp) && !key.match?(entry[:key]) + next if entry[:key].is_a?(String) && key != entry[:key] + + if !entry.key?(:locales) || entry[:locales].include?(locale.to_sym) + entry[:used] = true + + true + end end end end @@ -108,6 +106,13 @@ def allowed_untranslated_key?(locale, key) be_empty, "untranslated i18n keys: #{untranslated_keys}", ) + + unused_allowed_untranslated_keys = + I18n::Tasks::BaseTask::ALLOWED_UNTRANSLATED_KEYS.reject { |key| key[:used] } + expect(unused_allowed_untranslated_keys).to( + be_empty, + "unused allowed untranslated i18n keys: #{unused_allowed_untranslated_keys}", + ) end it 'does not have keys with missing interpolation arguments (check callsites for correct args)' do @@ -125,9 +130,7 @@ def allowed_untranslated_key?(locale, key) missing_interpolation_argument_keys.push(key) if interpolation_arguments.uniq.length > 1 end - missing_interpolation_argument_keys -= ALLOWED_INTERPOLATION_MISMATCH_KEYS - - expect(missing_interpolation_argument_keys).to be_empty + expect(missing_interpolation_argument_keys.sort).to eq ALLOWED_INTERPOLATION_MISMATCH_KEYS end it 'has matching HTML tags' do From 1992e9810475259a65aff4b91f521658aad6431c Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 25 Apr 2024 12:16:57 -0700 Subject: [PATCH 04/14] Queue GpoReminderJob as long_running (#10509) This job pretty regularly takes more than 50s to run, which causes it to trigger the worker perform time alarm. It looks like the time scales directly with the number of reminder emails it needs to send. Average time / email over the last month or so is 0.3s. [skip changelog] --- app/jobs/gpo_reminder_job.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/jobs/gpo_reminder_job.rb b/app/jobs/gpo_reminder_job.rb index 2d721caea0d..a30b2099237 100644 --- a/app/jobs/gpo_reminder_job.rb +++ b/app/jobs/gpo_reminder_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class GpoReminderJob < ApplicationJob - queue_as :low + queue_as :long_running # Send email reminders to people with USPS proofing letters whose # letters were sent a while ago, and haven't yet entered their code From e96c59e29e1c22e4c097193bab519a69f1ad9463 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Thu, 25 Apr 2024 15:29:19 -0400 Subject: [PATCH 05/14] Return a `Pii::StateId` object from `DocAuth::LexisNexis::Responses::TrueIdResponse#pii_from_doc` (#10498) We are working to return immutable structs to represent PII from doc auth vendors that read PII from state IDs. Previous commits (10470, #10488) laid some groundwork for doing this in the mock proofer. This commit takes the step of using the immutable `Pii::StateId` object for PII in the LexisNexis TrueID client. [skip changelog] --- .../doc_auth/lexis_nexis/doc_pii_reader.rb | 11 +- .../lexis_nexis/responses/true_id_response.rb | 4 +- app/services/doc_auth/response.rb | 2 +- .../doc_auth/redo_document_capture_spec.rb | 4 +- .../true_id_response_failed_to_ocr_dob.json | 474 ++++++++++++++++++ .../true_id/true_id_response_success.json | 2 +- spec/forms/idv/api_image_upload_form_spec.rb | 2 +- spec/models/document_capture_session_spec.rb | 21 +- .../responses/true_id_response_spec.rb | 63 +-- spec/support/lexis_nexis_fixtures.rb | 4 + 10 files changed, 543 insertions(+), 44 deletions(-) create mode 100644 spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json diff --git a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb index ab960428b51..412ad1eede7 100644 --- a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb +++ b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb @@ -19,8 +19,9 @@ module DocPiiReader private + # @return [Pii::StateId, nil] def read_pii(true_id_product) - return {} unless true_id_product&.dig(:IDAUTH_FIELD_DATA).present? + return nil 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] @@ -32,23 +33,23 @@ def read_pii(true_id_product) month: pii.delete(:dob_month), day: pii.delete(:dob_day), ) - pii[:dob] = dob if dob + pii[:dob] = 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 + pii[:state_id_expiration] = 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[:state_id_issued] = issued_date - pii + Pii::StateId.new(**pii) end PII_INCLUDES = { 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 b6f5aee7570..80989ff8282 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 @@ -189,7 +189,7 @@ def create_response_info log_alert_results: log_alert_formatter.log_alerts(alerts), portrait_match_results: portrait_match_results, image_metrics: read_image_metrics(true_id_product), - address_line2_present: !pii_from_doc[:address2].blank?, + address_line2_present: !pii_from_doc&.address2.blank?, classification_info: classification_info, liveness_enabled: @liveness_checking_enabled, } @@ -233,7 +233,7 @@ def doc_issuer_type def classification_info # Acuant response has both sides info, here simulate that doc_class = doc_class_name - issuing_country = pii_from_doc[:issuing_country_code] + issuing_country = pii_from_doc&.issuing_country_code { Front: { ClassName: doc_class, diff --git a/app/services/doc_auth/response.rb b/app/services/doc_auth/response.rb index 1cf51a779f6..58d9690a5a3 100644 --- a/app/services/doc_auth/response.rb +++ b/app/services/doc_auth/response.rb @@ -17,7 +17,7 @@ def initialize( errors: {}, exception: nil, extra: {}, - pii_from_doc: {}, + pii_from_doc: nil, attention_with_barcode: false, doc_type_supported: true, selfie_status: :not_processed, diff --git a/spec/features/idv/doc_auth/redo_document_capture_spec.rb b/spec/features/idv/doc_auth/redo_document_capture_spec.rb index dd765980c00..2ee2f3c0b6d 100644 --- a/spec/features/idv/doc_auth/redo_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/redo_document_capture_spec.rb @@ -390,9 +390,9 @@ allow_any_instance_of(FederatedProtocols::Oidc). to receive(:biometric_comparison_required?).and_return(true) pii = Idp::Constants::MOCK_IDV_APPLICANT.dup - pii.delete(:address1) + pii[:address1] = nil allow_any_instance_of(DocAuth::LexisNexis::Responses::TrueIdResponse). - to receive(:pii_from_doc).and_return(pii) + to receive(:pii_from_doc).and_return(Pii::StateId.new(**pii)) start_idv_from_sp sign_in_and_2fa_user complete_doc_auth_steps_before_document_capture_step diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json new file mode 100644 index 00000000000..2e151deb30e --- /dev/null +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json @@ -0,0 +1,474 @@ +{ + "Status": { + "ConversationId": "31000403205968", + "RequestId": "705004858", + "TransactionStatus": "passed", + "Reference": "Reference1", + "ServerInfo": "bctlsidmapp01.risk.regn.net" + }, + "Products": [ { + "ProductType": "TrueID", + "ExecutedStepName": "True_ID_Step", + "ProductConfigurationName": "AndreV3_TrueID_Flow", + "ProductStatus": "pass", + "ParameterDetails": [ + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocumentName", + "Values": [{"Value": "New York (NY) Learner Permit"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocAuthResult", + "Values": [{"Value": "Passed"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIssuerCode", + "Values": [{"Value": "NY"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIssuerName", + "Values": [{"Value": "New York"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocClassCode", + "Values": [{"Value": "DriversLicense"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocClass", + "Values": [{"Value": "DriversLicense"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocClassName", + "Values": [{"Value": "Drivers License"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIsGeneric", + "Values": [{"Value": "false"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIssue", + "Values": [{"Value": "1997"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIssueType", + "Values": [{"Value": "Learner Permit"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocSize", + "Values": [{"Value": "ID1"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "ClassificationMode", + "Values": [{"Value": "Automatic"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "OrientationChanged", + "Values": [{"Value": "false"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "PresentationChanged", + "Values": [{"Value": "false"}] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "Side", + "Values": [{"Value": "Front"}] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "GlareMetric", + "Values": [{"Value": "95"}] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "SharpnessMetric", + "Values": [{"Value": "64"}] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "IsTampered", + "Values": [{"Value": "0"}] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "IsCropped", + "Values": [{"Value": "0"}] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "HorizontalResolution", + "Values": [{"Value": "353"}] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "VerticalResolution", + "Values": [{"Value": "353"}] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "Light", + "Values": [{"Value": "White"}] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "MimeType", + "Values": [{"Value": "image/jpeg"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "FullName", + "Values": [{"Value": "LICENSE SAMPLE"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Sex", + "Values": [{"Value": "Male"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Age", + "Values": [{"Value": "54"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DOB_Year", + "Values": [{"Value": "failed"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DOB_Month", + "Values": [{"Value": "to"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DOB_Day", + "Values": [{"Value": "OCR"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "ExpirationDate_Year", + "Values": [{"Value": "2099"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "ExpirationDate_Month", + "Values": [{"Value": "5"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "ExpirationDate_Day", + "Values": [{"Value": "5"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Portrait", + "Values": [{"Value": "/9j/4AAQSkZJRgABAQEBYgFiAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9\nPDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhC\nY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wAAR\nCAGzAVwDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAA\nAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK\nFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG\nh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl\n5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREA\nAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk\nNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOE\nhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk\n5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDSkZiHQszc5AJqATOSshYgjg805xmNHP8A\nD1qEEBzxkNSNCRZNpPzHD56GlDMVwzHI9TUOxijKv3kPHNPZhgPjrwaYD/OUDeGbnjigONrR\nEkhvunNMUYZlJ425FNBzEMclDyfWkA8yOY8ZO9MZwaeZWVwckq/HFR7trA9QwximAHa8TcHO\nQaAJA+dyMSWTkc07ziqpJkkdDioiS3ly9ME7qRh8zKDwwz7UwJAcKU3/AOsHHtSecSFY53J7\n1HnCMO68ilLZPUBWHNAEjS993DDoTTd+QYyenI5poQBNpJODkGkMgPzDqODQBKZG2I+9iBwR\nmoiyqxBPyyDP0pxJjO3++MjPas66ncBk3jco5oE2WmuVEBffyjcfSo2v8ndENwdeTmsSa4aR\nt2/AI6A1X+0yqpVJAB6ZoJubU00vlsGJx/CKgkvo22fINw4zWOJ5Nykszc5OT09qHZiQQcZG\ncelAXNCW4Ktt3EFunFR/aZmYOuSydCTVNy5YNu5qRWY89M8kUAW0uriR2YlcueTipHMq5yQc\n89aoLceWwVs4JJGBV+OYSwA8ZHrQAn225wPmIwOgpY9VnRyCxZW5FIysGZtykMOgPrVVI5i2\n1duRQBpw63tRfOjbcDjd1zV2PV7QzfNJhW65FYcsTIOXGSOaquuBtLA0Bc6xdStZFdFmJA5B\n3U4XUcgWQScg4PNcYMhuOM8HFSK7IMY684zQFzs/M2yFVIZHHBxxmk3O6tjiRDke9crb6nPB\ntAcsq9Ae1bNpqSXHll3CHoQe9A7mqHAcSHO1u1MJKq0ZPcEGmxn70eeOxoJ3AOD8w4YUDHu7\nlFkXrnnml3N54Rh8rjIpigb8MTsIoAIXaTnaeKQwaRnQnvHS+ZmRW7NwRzimsyAq/TPBpOAG\nXdxn5TTAcC3zJj3FOEnMcg69DUQc8NzkcGnDG8Lj5X6UASbirFSQQ3IJ9ajZnMQ4BKHrjOaZ\nlnjYHqnQe1OB6OD8jUCJd3zqwztftmmea0RKEk88GkXgFM4I5X3p8fzpnjPSgZI7Es2Put1q\nEAupQdQePpUkgIDKSPlNRk4kWT+VIBd/IkGcsMY9KaoyzLnjtk0vRiv48mkP+rVu69aYC8bc\n4+buacDhxjoaQ8SkdmpFUBGHQrSAVSVQqzjjkU0PwjkZ7HAobccNnjpSgKpKnvzyaYAuBIyH\n7pHFMClkB4+T9Kr3V8sSBd3O7BrNuNTfov8AGMHJoE2a81zHEyMCCJOTxVc3cYZkIz0IIrDF\n3KflDkY555qQTSmMTM275sY6UE3NhbxJI43Q888McVIbgByo5D+hrA2uZDkDGCfpmlRxsHtz\nmgLm/JIrW5VmG5Dyc9qwr+4JkG0blNMad13HcTn3qpLIzHcT060AIzKXwgZQBznvTOWO7Apd\n2RjORQMgkgCgQKRz82M+op6nceufU0wknqc0hyGyRweaALaoN6gHJxmrixx8kbmYjgdgaoxF\nSuBkuOcn0q9HKxVW29qAIWICLNuUMhOMrmoGLAEnaB/d7n6VLMrlsZABPTFR+UmwbnKhSecd\naBkYdmDAM3DAA57U7zZUYMG4I65603KJ90E/jSM/HT8xQISSTfwxNJIAIlHB3dBnkUmQeSKF\nbZuGMljwcdKAHoQoy4Uqe2e/pT1jQgsD+Hp7VDuIHOST1NICQc5Zh6GgCdoyqCTysjoD2qHD\nKxbbhuD1pFVjuGB1zt7AUoHzFgTk9M9qANO21aaLasqrgHAbPQVt288UkyiNwfMXP0OK5AKG\nGBxjpU1rczQSLuUsAevpQO51oJdcbgStSbvuPgfNwRVOG5EqxziVSsnBAHQ1aj3MGQAYXkc0\nDTGlRukTOR1HtSYO0YJyp60rMAFbBJHDYpdoLsmcBuaChB/rCecOOKACQyqM7eBQqnHrspwY\nkoR0YUAIB8yuR14PtR5fBiJCgcqadgDcnpzSbgNj9D0YetAg3EqrZ5HBNMlR43whBGM5zUgw\ngZQPvD8qaGwADnigZNK2WDYHzjGcVF/yzkQjnqKkkVQoGM7Cec9KZu/e7hypAxSAbniNySD3\np44YjqGGc00KTuXv1o/gT2ApgGcr05U0rFtyu3RutKgxIQAPmGetNwXjIxnDHn0oYh20lmXo\nM5GaqXdx5UYkZgSPlq27KmxgeMdPWsDVpVMsiqu0YBoBle6uldsl8HqAOc1S5LANxwCAf5Uj\nFtqkDNPd/OdSRggAZ9hQSNxuPB6U8KobPIc9OaXgwFI0DHdnf0wPSk2OTlQOvc0ASlgVBJ59\nqcyAKWHQjiohGw49fSpCpRVDupwM9aAK0q/KzbtwXtTGGQwHQ9KldwOij5unuag+6MdcfrQI\nMkLwOvtSj7pPqaejHfjtjNKpBZlIOQBg9qAE2gAFl4YcURru47VIYWMalWHB6GporGVHUMVC\nkcYoAiiiIbHTNXVKxoY88Hn8agAMZbegyOc5pzzhvu9aAGuRs++Mgmothcggk5o+86jONxOc\n1Mi4G5eADQBCtsRkEgYGajKkBmLYxxipJXJlIGeaiy20nYSD1PpQA04HBGSO9JkFc+lKT/EH\n+uRSDaxbcr4KEL5Zx82eM0AJ5gX3pQ26n7G248vGKUqibR5fb1oAQAAbh+YPNRhs5PqSaVmA\nUqe5zSsquisMDPZec0ANBJOB65zT926QnPvS46kcDkGmKqBkZXycEFcdKALemXTw3Dg87sEL\nXSxyrJ5cu7BYcj0NcduIIYcc4rf025RrUpgKUGTQM1wfvD+9zmmg9P8AZpFkVdjZ4anAYaRf\nXmgoUcTD0YU0KfKKj+Ggt+7T/Z6mpF4diOAR0pDGudpRz0IxxSbcvJHjg8ikwWjK91OT6VI5\n5R1IpgNyQiY7HmmyEo3yj73NPCsode/WhCWQdscUgJXVWlkBH3iDioSwMZK4AA71NKSsisBk\nGoAvEinjHNADsASjGeRTVOVZfTmlz+7Ujr2oG3fgY2mmAcAo4PQ9KcA32h0GfmHHvTP4XA7N\nT92JFc8HtSAq3TCO2ZCx3JzzXPXkhkfd3b9K19S3CeUAEZFYTPhjlc9uetMljRzwTnNOOCgU\nqcjuKYpAZfSpwFKbgvzH739KCSLGGGM/nTy6iPd9456UHaiK4Ay3WoiMd6BkscmOv3s8J2NM\ndhjhRzTOpznBFCjlQpzmgB5lcLEeyDANMztP3R0qykReAx4ZQSS2amWwL7XCYLcGgDNRgWzz\nz6U92wcAbeOc960JbBo5dmw9OoqlNCQAw3Bt3OaAtYhDHaQCSPT3p3mSttJkc47A0NgMCF/X\nimx4IJJx9KBEm4lcEnmnBgqntTG6ilA/vdMZoAtxxRmVZHbOecBqlCqXwuQufrioYlhKD5jk\nVMJDvVQxII4WgZWky4UkYx1qNleKTAyQw7VbWIHcAwJ6nNOMSx7GB5oEZrb2+YIeKNhkKgA8\n4NaBij3Y5/E0xzj7hGF4yBigZXCKGIIbZ6UhVTwQ2evHFWFhd1JBpgt3XO9SfxoAqthjyvvi\npEHmKqrGFUEnjileLa28EjI6Gm8qgG0g5y3PWgQoXI44HambGD4PzfjTixOeMZOaacs2T6UA\nNIPIxgZzVizkEc4LHarfexUQ5A9hikLbWBHIyKAOptXEloGOeTxntVvOWVhjnis3TJA0R5yC\nOnpV5D+4XkcGgpEgXCSDv1pHJAjcge9OB/fHgYIpjrmBlx0bIpFDgoErKcnPNNbHlHjoadn5\n4zzQmd8iDtzTAkP3kPqKjUhSw96dg+TG3WobnCy8buQDxQBYmyFjOe9MAAdiW+8M4p8p3In1\nzUeP3wAUH5TQA1T+7zjFPYAMh7HtTePLcAd6cfuxmgBBhC4NNbJt1cdmpyjMkhwM02d9lr75\noQMx9XlLTkspO1RnB9elY8iMvLHGTkD+7V7VZXNySAQSBVBmBfHUnrQQC8PuKg45I+tTF12/\nKu3PP4VAwUHhwfYetLtxz1zQIsx26vEZGJPsOgpjLGrMA42MAMUkLbeS+M9RRKY3YCNSM+ho\nGNEcW9lUlt3TttrStbIh42O1Tnmm6fZAIZCPmIP/AAGtaKABY+3qfSpbsVFXGR2as7AEnvyM\nVYS3C2y5HKt2qZECuQOuKDuNvz0yf51k5M2UUMlgzMPmwCKxLu1PmSEKzbWrpCh3rg9aqS26\nu0vFEZ9xygco8boqgpjP3TUAXOSOBW1dQiMoSvTgDH3qzmgEchCqCD3HXNb3OZorg59acsW4\n9eaVYypO4YPUj0p64VsqMMKAJLZBFuy2D39qsPJHsBU8g1XRydxPBzzS8bRg5Oc0ASecilyB\nn2oa4EiqxjG1RxSCKUgHZ8rZ20xbafb8uQAegGcUAWA4Y5455pPNijDfLk0CznMiKFKlh19K\ns2+mO5LEdDz70r2Ha5We5zgrGBjpxTGupXk5weR/D+lbcdgiGPKg8dxUosIEuH2xjPXOKXOi\nlBnLby4BbOc9BSu5OQU5HNblxYxND5gXEmfvD61RudPYH5FIzyD/ADpp3JcbGZu3A4P+fSkH\nBGd3TmpJojC3yggbuPWkwR94kg+vWmSM3FiE3cAkqM+vWmlMgKCSB3qQhCwK7sj1NByOvp0/\nrQBb0y7jhuQjNtDcbj3Nb6NmEgHd1P61yZYKUZB8w61vafMDCwaQuQB8o6UDRrtjehPGaAMm\nVQcc5we1RqweONhxUwPMpJoLGgAxxlsDkZI+lKoImOByVyfam4AhXHBB/CpPvTg+q0ARLuFu\ny+n+NSFiMZYD2NNXOx/rQ/b6UhDnOYwfU0g4mA9RmlkBEcZJxnoPWo8kSs/tQMVSwBBI701z\nxEDwaSJsxFiPmz1p7Y3Ju7UAC4EkmewqCZi8LcDH1qdeshAzzVSdsWgXuxpgY2qjbdcdgMVn\nMMufTFampIZLjgE4HastlwTuyuPXvQQOibEaRkDauT05OakjUcgZx2zUSnBwBk+hFTwkBufS\ngAEf7p2I6Hip7dd9yhKjO37wGOtGV8sLsIyQK07OGJZgE+Yjk8dqTdhpE8PyLIu4HBx09qsp\nkJEKZGhCscjr1qwVAaMZHrWMmbxQ7A8w4PQUgU+UfrSjiSQ1IoDRKc8k5rO5qkTRJmaInGMc\n0rQJslYHqakiU+eCB0FSRgiKRjwCaQzJn09GSPLNkt6VSl0hVlcAuABu4HU10/2YkxZxThBl\nptwHAq1Joh8r3OI/s4SK77m3Z6Fe1Ml0lYpVIkY7+cEdK7FrNfs6FSBvbB4pX06KSdVbIwvW\nr52Z8kTloLGFtzsWOOBUi6fHlQmQM88da3l0oLHKUPU96sR6YVeAAqRwT+VHOxcqMuPT4hMc\nBxgZqY2oW1DhTyewrZjgAaZjycd6SSPbbIpP3mqbspWRjtZr50YCsDj+KnR248uQe9arxqZg\nSOi1XEai2kfPU0nqaKxUaAARGmsn71/cVakTDxjPQVAxJaV+2cVJfQouoMBA4BNRSIpdQc4x\nzU7riCPrzTAp+0NkfKB3PFWnYzkjFu4AqO3OQ+c1mzBlKn2rpLmIyWkh27snqKwbtCrKCPr7\nVtF3OaUbFUEHr1704hccketSGPAPA5NV5MjGBmqIGEk4z1Jq3YStEGHGDwQep5qk33weafCz\niZdvY80AdhCySLGc/manHLyNWXYT52bQv4Voo6mKTexDE8YoKTFb7i+hpw5m9gKRgVijGQaE\nJZmGAeKBhn/R3P8AtU45IXAzxUQOLcDA5antIFbGTxSAknGHUHnbUIwpdmyR0xU0hHmuSO1Q\nHAj4G5icUDFCgRqueeuKcxPnc/wjpQAWmjG3pnJ9aRcAu/vQAm7CSHHOaryD7pXGfQ9BVpxu\njA24z1qu4DzNzgKPzpgYl65EjHgEjH1rNyCuScnpWjfKFLMx5JIWs1TtZS65K0EEgZPM3RqQ\nMdz3p0QwGyc5wc9qjQ5A7E549KkjXCMTnOABQBahZ1njZQrAMM7uR9a1bLl5JSw+Yc46CsiA\nEvgghc549a1bUMsDYOMn0qXsVHc0EOIPujLGpx/rkLDaMZqEIMRqe3WpUO7cxPbiueR1RF7O\n471ZRAoiTBNV1+5Gp/iPNXov9YfRakslTCysc4Cjj3qUBTb89Wao43zGzAcluKsoMvCD2JyK\nCJEhB88YH3aUA7JGPc4pAeZJPfFKcLGik8vVmIjIMRIo5xmlUAzSuR/DTwM3H+4uaYoH2eRs\nYLcUyRoQLbKcfMWzUgGZx22r0pxHzKv90U0H55X9qAGYCwytxknA4pXAJjQjPGadt3Rxr/e5\noJHnlv7gxQNEZBPmnb04quVAhVNv3jk1MSVh6cuabIP3sYHAUZqS4ldyGuT6KtU2IELt2JyK\nuO4xIzHB7VWkTComc5OaTNkVXAfy0x0qFgHkkYkgAgYq3IFV2YdFFVMFYi2cljxTBkcmPsyo\neCxrJuoczZAxjqa2Gw0qqR0HNZlwcvKcdc4rWBhNGfIgCuVw273zVOSPARQ5AU5q993bnjkm\nqlwQMnNamBXIx796Fzu6bTjjjrSjkckYxnIp4wpXGWBzz6GgRf0cjcQzkMoBArZhJeIM20ZJ\n5x15rnbEus7MhHXjFdFG37yJWYBTycCgaLu1WljXHyrmol6SN60/eMs2eAKYc+XEgGGY80Fg\n648lT37U18lzwPzqTOLjnkJ0pu3JJUE884FICWU/KxwMsajHBVe49KfJy4QjAFREgeZKPoKA\nHqcs75Bx0PrTWHAGevNNK4wo79acVDEnb096YDhzNj+6KhcDyXcdTT03CLOQC56UyQYIjU8D\nrQBkagjbkGAV6VkYcs2QFA9TWzqDfMWGMbsLWOc4xQQRkkjjPHpVlXQRdWGKr52HPX6UqFiQ\nWP3ic0CLtuw+Y7s5PFbFqxBiDKBxuPvWJZ7RhdoHckmtm1YFHLDO1cCpZcTRjIYu4P3eMVIm\nFRVHJY9KroCqov8AeOatKdz4/uisJHVEkjAeZUAztqxGwWJierVTTIRm7ucDFWUyZUU8hOoq\nCy1F/rI17Y5qeOQlnfj5elV4flkeTGNoAFSj5Yo0wNzNk0ES1Jkb90q/3jU2Q0uCPuVGhVpc\n/wAKClDD7Ozj70h4qkZMfnbG792OBSlcmNPxpCQZI4gc45NKsgYPJ07CqIAZxLIaTpCi/wAT\nnNA/1caHqxyadlTIx/hjFAB1mz2QVGzBYWccljSqwWEsermkk5eOP05NA0hGIMiRD+EbjURk\nGJJMZwMCjzcNM+OcYFQSR/LHFnljSNYxGMf3cakZZzk01iPNzjhR0pxOJmOPljHaoSSId2CS\nzGpNUQj5oSf75qJ8bwh4xzwKsy43qqkEJ0qu/wB12Lbtx44xQBAMKzMQST0qncjCKMAd6uMO\nETn1qrcn59y8FRxWkdzGZlT4aSRmIG0AAYqncY2e5PNW7rlPlzluapSc8Hr3rY5mNUQbG3l8\n/wAOwVGEA3lXzjoad0FJ8o4IpiJLUAOo56/wmuitGAGWb5VXsK5+ErvCgYxzx3rdtmKxRqFI\n3gn5hQMu5/cgd2zmnkAyqcn5F7UwHMgbjCD86UHEef71BQin90zddx4pS2w4D4+hoK4ZU7Co\n2RpGJCr6dKBonkYASN68Co5B8iRjoTk1K6gjaRyvUCoATgsfU4oAXd85b+H1oBzFkfxHigA7\nAvryaEOWzjCgUAIwzKgzgr2qOU53SdO1OBbZuIxupzjICbeg5NAGNf8ACID94npWW7bVwGya\n1L+RRJnbnB5PQVlsqOGIBAPbNBBGyg85xzRhFAUZ3L3FJGpJI6L79qWNSzBcA4HX1oEWLcja\niHh1yF98nvW9brttUiyPmFc7EpWVUaQDJHAGa6O3C5Mg5A4FSy0W4zl9x6AVMjEREjqe1Qcq\nEA6tU6fM2fQVhI6Yk2Qdi9NtSocGRx9BUIyUZv72BirMcWGjTt1NZmhKBhFGepzTw+6Ut2UY\nAqMMWV2UcA8U7afLK4+Y8k0yWSJKI4jk/NIamDp5sSMeF61CEDyZ58uJevqaFzsdm+83SmiW\nkWkkAEsoOewqTaAkadzyaq5wyxr25NSLI2ZJjzgcCqTM2ibdl2f+7mm5K25PRpDTBkKq4JZj\nk0pJeXP9xeKBWHlg0ixnotQmQBZHz1PFMGfKaQgh5G6mmyRqJAvzbR1oLSGu6BVjB5bk037Q\nokeQjIQYFJsA8yY/RaSSAiJY16ucmpNNBjy/6PtA+Z6Q4eRV6bRnNPKgSsx+7GKhOBGW6M/e\ngojLDDuMHOagblFXuMGpZUw6oP4eTUBfKOwOCTgUCEZsl5MfdGBWfckrEEYcsATV58rCiZ9z\nWdcSbpm2kfIK1gYzKU8gFwWAzgZ6d6oNxuPUnmrUvyr6k1Tm3bumMCtTBjOSM005ABx8xNAZ\nsc8/0p4UnvwO2aZJZhjG/AKrjBOT1rZhleWUMx4jjEajpgVhWz/O7EE4AGBzj3rYtEJVUB+Y\n8knvQM0VG1c5zvpz4/dxYPy8mmLhnXqFQYpynA385bikUKSTHI/vxSh1hG1zg9aRs4WP+7ya\nZKvmNuPNAyWdiNzk7mc8fSoyDuC5xjrUkxzIzHovAxUR4HqzGmALwGcnpwKCPkCDOT6UqL+9\nWPIC+5pFbD+Z17CkIXHzZ/hHFIQdjHOC3SjDKgUZJzzQ/wB7d0VRTGYerrmRYw2FB3Egd/Ss\nwEF2zgD2FaeojKmT+JnrPR/LZmThiMFv6UEEeEx8ud/PHr60cFsqcZ4NBbEbBcDcAM96cRkZ\nAwc5JAoEWbaE7Rlj6YxmtmGPyYkjTgDBNUdPRHTzQxJB5B9avqT0qWXEsROxZ5OqDgVZ5CkA\n/M1QwIWVVGCOpq0ieYS2cY6VjI6Yjs4cY5C9amiuMRMzYy/C1D9lcKihslu1JJDIOMgIvTFQ\nWacW0yJGo4AyxqwuAXfAOOMVz6yyIkhDEeacVOly2Y03t/tc0yLGyEKxLH0LnJqUKrPz91Bz\nVOC+DO8jkYXhQTU6yL5YAOWfr7U7ktMdt+QuvVzgU/ZuKRZ7ZakV1L8j5Y/u/WlDBVzzufpQ\nSCkFnl6YOBSBAIkT+Jjk07glIx25agOC0khPAwAKZIHDydMIg4phjPk72+9IeBT26KhJ554q\nJp0LZyCqA9aY1cd5Sg+WRlV5JqJ5VBkl4wOAM1TnvmSJmU/NJ0FUZLpneOIHjqaRokX5Z1CL\nGCCzdaqyzbpGcY8tAAB71Aiu2ZMnOeBUxspSFQkDPJAqTQYspaP5vvueT7VE2DKqAABRU3lE\nO5P3FJ5qF+F6fMeRTAgMhO6RjyeAKoXu5So/M4rQkJZwoGNhzVa7yyPKzncT8oAq4mEjIuBy\nDnjFVTvCnbklm4HWp7ogy7AW5yx+tVYpCsnmqcFehHX6VsYMIgrglm2mmlirhuOTg0qKoyGP\nI5ApDnaMknHYjpQIuWcWJBxweSK1rUYdnHQdKybeRgc/xE4rZtgrCKMH3JoGWlH7sL3zmnna\n7gHAVaYOrSDGBwPel2EKoY8nmgoTcQjZJDMaUOIRsyvHqaTguc8beOKQR+YN2aBk0qkIYh1b\nk+1RggsWPAAwKmbo755PAqADcVTOMjOaAGkcdyXGSaX0B4C85pw+ZvYDFNA+QjHzP0+lIQ5W\nG1pOSM4qKYkbY16t1qbAb5R0Xk+9QHDAuwwGBwaAMy/fdIVQb1AwcdsVlSKVBO0564rditir\ngGQMCM465qrqAQyMVQBAB8o9aYrGUMMDxToW2cFuDTpNu3JHftTIE3SKMjpnHpQI27PJiRM1\nZgJMzKRwvf1qC1QR2zSDqvA9KsWyO21CfvdcdqhlxNCEAIMABn/SrqoqMkY7AlvrVReDnjCj\nFSPMFiyT8zCsWdSRP9oAJlIGAMCmeYrRKpYBnJJB7VQN0XmEMYDbFyTVO4kkDPJjkdDjimo3\nJckjWlaEv1AVBVfIxvBBZzxWE145YIH3Bh8xPNXYxnc+8hU+6T0quQz5zU2q7KpboM4FXbW4\nZSZGwcjA9qxI52WLcD9/jmtCFw5SNRgL1qHGxpF3NNZCAqdSxyamSXc+48KgwBVKCTcWk7jg\nCrflgoi55NSNoVJtoLdC5wPanNIN+wHgDJpPJBkJYjEY6etQkAQ7ifmkP6UxWQ2W7dlaUMQR\nwFHSqUjvtSJsAscnBp8zDcqBhheSKz5ZmIZwOTwKFdlaImyGfrwvGKAiKM5G9v0rPmJCrktn\nOTjvQlyqzFwpCDjk1fKRzm4m3zAoZdsa5Jz3xT1f5WkD7i3AOcYrIjmzECuN8n61YilDS7cg\nBR61LTRSkmaJjLxiIEZzk1BPGT5jkjaq4GKckx8t2BHPA5olX92q9wOQOaSKMuUbBjnLc5qK\n4UFec7UHJFWZPncnpiq8+ViIPzbzzVxZlJGNcrtYsCQxOAPaqEsYWQ5Lc8c1o3bZl29lHP1r\nOfnBbOPetkc7EPHr1p6RtKdpZEU87mppwUyPXHFLsLhio5GAKZJctNrFmOGYNgEdK1rdCq9f\nmY1Ss12eXHtGW5yOlakZyScAY4oGPKAlVBwEHOO9PMmSZPwApuMqxDAZ9aMKzJHn5QDmgoNu\nMJ15yaMMxPlqSBx+NIrfeIzk8CnLtUY3n8qAHyL+8IJxtqLvkdRUkp4OBgsaj25kC56UDDbt\nCjOdxoDAvuHSPgelCkkMe3akIJUKO9IQpbamerNxTJVywjxwtSbVZ2BJwtRS/IpfYSztimBJ\nBGuxic56LWdfWoUc8M5P861og2wIpxt61Bd/6uU4O3GAM1nf3jflXKcxPGFyFHen2K53AEBi\ncc0+cMoClcE0+xhJmzwFTqc1Zz9TRjRhGsCHGOvvV21bDO+BxwKqIrrGSf4j2q9AgOxegHJr\nORpDcmLAIq7clupqjelmYLyEQdqugFwWx8tMeBdgVepPIrNOxu1cisAEjZiPmJ71DqsKysm1\ntu3svU/Wrqw5kKrxtHNMETKodlDMTxmqUtROOljBuZ/kWOO2BGdgYCug0i3VbNUkOZJDlifT\n0oKhwEKqNvPSg4MjMAAOnFNzuQoWY+5SMT4QAKgpiBQN6nlj0oMYbYMHk808qhm4HyoDUN3N\nUrE1u+5lQdF5JrRicKDIecdKx4yVQnPL4xg1owtuMceeBycGpGWTKWQDoW61VnmG/g8LkVLJ\nLtR37qMCs13ZwiZyWOf8aASIpH/dn7wdj29KLSya6nVGPyLyTmlPzSbv4RTElmhLvEWVmPH0\nqkJkl/BHbQOyqePlHesGeByBmeNcg5GMHnmukkuZZIVt2xgdTisy60uCdjK+SBwOa0jJGMot\nkWjQpOuGLMIhkMPXvU1xHLDtZuknSrFvEtrAltB8o6k0tyfPKl1yF4z6U20Ci0FvPuKKfujG\nasLKCHlP3mGAapohSM99xHJqwoBdV/hTNZM2Q1wBGq9+M1XlBYknoKnLfKzkdeAKjkRmQLzz\n14pxJkY11EQxxyGzzWc6nH0rYu1Imz0VRxWbKnXGfmNbo5WiGOMySDIJUnFaP9ksflixg8km\nnWVszbcj7ozWvbrtgJPc8UnKxUIcxjwRtCzcdDj61qqP3YU8s4BJ9aV4gXUYHrTlwGZtv3eB\nQncHGwincwz2HNKCFXf3Y8UnKxKuPmZsnjmnMB5oAXhRVCEwNyqOwpSm8kqKT/lkzj7zHAFO\nULGoU4yPegB02C7kcKnfpmoSMLuz941LKPlAxy1RHJkwRwKBkgYfKmMY70jHO5zzjjBpmflY\n+tO5DhR360hByqFVPLVHIf3ind90djUikszHGQvc1XuOE4H3iTTAntJPkkOeWzj86muIt4EY\nXtkmsyxYte+XnCr/AFrZAPlNIT94VlLc3hqjm9RUrLvLbhjHTpSWVv8Auhk4Zzkk1Y1JeEj7\nA5qSzZZBkLnYMD2q+hk9y1EA54+6vFWY/kg39ycVBCoWEDPJNWAu4LH3FZSZrBEoXIjjX6mp\n44t7vIx4UYAqOEABpPwAq4qEQrHk5c9/aszUrtbssOB1c017dnl8snCqAeKvP80mOyjNRhWE\nTOvJbgUwuUPIbaXxyTjnilNm7MiZIB64rRCfMiuTwOhpAwHmSFeV4FAFNohGzt1VRgVTf5E2\ngnLEZzV6XIjRT/Geaoytulwei5HSgYqgecAozsHrVy13LEzkdeBVCAARsQcljV+NiXRAchRy\nKGA6UYCoTweTVVmBmZz91eBUskm52PTjFVtpCqvXPJoAdHjylwDkmnmEs6gAfL1FNVy0gAHC\n1cVSLfJHzOcZoAqCKRQWdOS2BS4YbYyvWtDyRvRACQnfNGw/O5QcnHPvSC5SRQCzkZAGM0wx\nMYoweWJHFaDRKI0Tb35pyhRM0mBtRcAe9ArlIW5MmP7gpoi2RFjjLVdK7bbOPmc1G8YLlOcL\nSGilKm+WOPGNo+aoOVLttyAMDmrcinLnPtVdkXCqvUjmqTE0Y9+TiOPkeZ+lQwwefOdpbYnA\n4rQvRGZDICBsBHTvVTSWZ7sxqQdxBDeldC2OR7mhBb+RCeSS571fRQzIMY2j86a0f7/axJ2g\nHmnyyiKBpQMFsgGsW7s64qyK7kYaQryTwaiYARqh65zmmGbfsjzjuc0qktvf04A9a1ijnqNP\nYcvMhb06UuPkzk/Mabg4x0NOyHfnhUFWZigBnCg8IKRlLkseKFO2NmBwW4FDFYgqk4IHSgCa\nUF5cEDag7VWBPl5bkseKmkwFlb5jnio9u3y0PAA5oGB5IHtzQp2uxwfrSxjAP0pFBEe7Od1I\nBeRGq4zu61DeAO5DnCquc1PnMyj+6KjkBcSN+HNMTM2zlAuG8sbhnC9s1vYASOIg+tc+oMMy\ncjk10UR807j91V61nM1pGLqisblsKcAYzUNqVWMKBg5wT61rXsX+il8ZDZGayYYjFcxoc4XN\nCegprU0ocSzLtXIA9atxg7DJt5zgVXssLCx5JBOSatIP3KL75rORpAsRR4VFPcZIq0jAzux5\n2KMCokIMpA7CpUURwM5OSxxUGgoOIdwBy7VOFZpFQ/dAzxRHE4aNQuQOpqRcEysOgHFUQ2Qk\nHDsfwqKRNvlqP4+tSNIREqY++eKV1DXCqOijmgEyjMQS3H+rHFZrvsidsZLVdunCxOM8scVn\nlGZgo7daDQWJm3Ku1sCrkKkRO/Rjx9ahhTc0jA9sVbRMW8a9d1JgiB84jBT71MU75yzD5UBq\nzL/rRx9wVTLhUfb34oAWJtkR9WNasBLtGpUbVHNZQGWjWtK2OFkfqB/OgRbQYSVw2e1NaIeV\nHEc5PJ5qWMbrWMMACTyKkVcXWeuFxTM7lYH98WPRVqMA/Zuc7nPepmiYRu4/iyKiZCDChJOO\naLFpg7DzVDHO2oy5CSuB6j9aX7zyNjvSSMRDGo6MeakorzjComD1yeKquR5ruRwBtA96uzt+\n8PGcVS6QuzDHzd6aBmXeHEHzPgHqT2qXw1AkuoCQclASTVTVW+aOFf4TnP1rX8OYjs2c9hn6\n1s9jlWsi5IoLO5/U1R1OY+THBHz1yTV+RsrtIwWrD1eQtdgI2Qo6CohqzolpErQy8uQenGau\nwl3RQD8znn2FZ8Me1SH4YkECtGOMmUAcYHWtjjJRxID97aKUfcL+p6UYwGejrEg9eaYw/jjU\n/U0yRA7ZbBPTmpQPnb2FEKbkLHuaQDpsGNAcjcegpnPnEkcAfhUspHmhR2qP5vmwMZ4pjGL/\nAKvI5BNPUYZV9KRQfJUEY6fjSqQZvQAd6QhFJ3SPimyf6lD6mnZPkvg459aSX7qL1pjMm/JE\nnyjaka7iferukX6yW7RM5MgGTmo7hQZnLD5SMVgiVoJgwyORk5pNXQk7M7ny/MWNSOOtR3Vp\nEZS+M8dag0rUvtzA7MFF554PvVydx5MjYwB3rDVM6bqSKcKgW6nGBnBqZeZkweB2qNSPKjXt\nU0ZBlb2FJjRZtcLHI5PU4qyy/LEuepzVJZNtmSemeatLOHliA7CpKL8T/vXOOi4pjt/ozZ/i\nNRxu2JTnvTJpiscQPc1VzO2oPnzEGcEU1rho1mPU4IzTGmRrgqByo5qpOx+zsAx6+lI0USnO\n+5Y1I56063YtKeeAKR4/MlVQRn61CoktpHJOVPWmgehqQW7Nas+3O41dNsY/LXH3QKzLa8VY\nI1BJyenpVs3rNc4ZcYX160C1GzAYmbPNUZUAiRR1NNuNRjjgcOSCe3qc0onElwoSJkwO/elY\nq4kYxchjn5RWjAw+zMWP3iKz1PMjVZjkIt0GRzg8/WkBsA5eNc9BUyEYmfqelVN2bgkMeO1P\njkxbscHk96q5jKJJKoFvGg6nmo3U/aTgfdqQn95EOOlMyQXbjJamCKm4iDt8xpr/AH0B6AUp\nwIUGO4pJWxcduFqDUrO+4yt71RvCfJROmetWGOIWOe9V5CXljQgdKcdwlsYkkb3d0QckDgt6\nYroLJFgsERDlckfnRDaKRIVAAHarMUQSBSwxWkpGcYWEmcApvbgDpXP3DZupCTweB+daWpXY\nSZhwQoxxWNG2+Nm7k96cEKrJbFhU/wBWvXcc1eRmMjegHFV7dctGp+8BnHtVmMKPMCqc/WtT\nBCliIwD6n8aXB85Vx90UmwsE3HHtUoJaVmJzhaBkY4DE8fMRQPkUDJ6etAwyMD0JpW4Y/Jmk\nBJLzdHHpUfqfm4p8hKXDlecVHgGM560wF4BiBGMd/WncNK5zkYpCfmUZxjihc+ZJk9FoAa5B\niXjvT+CyjFRfetx9alU/vlFAFSdcNNjruFYl3br8rMuN2dzV0DRkGUHkA81mXkRaIDOFI6UE\nmfpt/LYSNLHuCHgjPNdMt/Hc2BdXUhmOcHJJx3FcncJl35PygUlu7BPlkwCwbaOvvUtXHGTR\n2aHLRgjFOIYtIw4BqvbMzmEr91hnJ+lW/mVX5rFqx0xdxpJMSDOFJ5qaLC3A/OokJMS8Zw3p\nVnGZw2O1Zmg37RthkYZ5Y4qNpGYxZPQd6aE/duAvSkxh4/pQMczF5JCxzUbSbYNoXIJo3fPL\nxUZGYRnsaYCyD/SFxkHHembcq/ds1IB/pIL9Mcc09Fykmwev8qYiswZWUr8tSI4a4di2cDFW\n3tiyRk96Z5DiV1EY4HUUxFJrdDHl1BO/jP1qcuPMTGM8jNLLEVtw2CQW/KmtGVkTH8NAxI8b\nXycVKhLCFSelMXH7wEU/eAsZ9xUsDQiKiZsnLAVIsoNt3HIqpG2Llz7YqXdi3P8AvClcViyz\nnzY8HIxzTFc7JDnvTXOJEOe1QgnEgK/LnimOxLJ9yOoZTmRz7UySV/LiOQKRstK2T2oHYquQ\nYD25qFH/ANKQYzUknNu/sahi2pco0h2j61cdTNmlbj9zIxToetVtSvY0hRVIBBHQ1WutXjVp\nIFdAu4jPqoFc7d3ZdV+bGeeKtRvuZyqJbEst40sr8qd38qfbAvGM55OetZ8A3Ssec44NalmM\nQp97A6n0rU573NOMBHUr1I6mpMfK/wBeT+FAHzrSoTslXpk5oKQ5l+ZBnjrQn+tkI54pC5KR\njv0Jp0agXEn0oGMBPkg4xzTj2J4ppUtb5Azk1IyI2C65OKQA3/HxjHB71GeIzUsxIlQjvUZB\n3tk5FMBuc7W7f1p24h3A7ik4aJCD0PNOLDzTx2oAQDMIP8IalL/v4xjqKRGxEyjgA0OcrE45\nPQ0gFwGEoqrOo8lSRk5NWhgOwyfmqNk/dHuBTAw723CzBF/iFZEisjFv7p4ArqriFnMciA4Y\nY6dK5+7tXVjt6E/SghnQaVOz2tu2OOn1rW3b5JFx1Fcxo9yyxKpPzK2FHoPWumi4lz6rWM0d\nFJjA58obcr81XY/mnVf9nNUSo2MM5wauQN8sTY9KxNyYIvlygDHeoLpQIY5MYFWkH7yUeoqC\nb5rfCj7tAFQsVmPHbtUYGYyPeq9xczRT4KKRjr3qtDqcjO6bRgcEEc1aixOSRrIi+bG2eo7c\n1ZijAZxjGD1Pasn7ZKsUbIhB6HPNW7fUnjk/eoDuHTFFhXNZU/0eFjzk0j5Fyw2ggjGaorqw\nMCh0KkHipzqEYkjLHKkc0APaLdAyDsTVOaNg6HkdqnF9CTKqk4PSmtLH5aNvyU4pFFbbiaRD\n0IzURI8lCD0q45DT9vmHWqp/1TL0IPrSGSJId+e5qaN90EiA5Iaq+GLRMpIJHc1ZhiAaVM9e\nc+tICVmy0bnvTGwJZFxwT60rkmDpgKagJzJgHgjigYpK+Vg9B0pZOJQQOopqrmORT1FMk3Hy\n23e1NCbI3KiB0PXNYN9I25fm/Wti6YIZsnvXOXsrGIANn2remjmqMqTPhpPkOSeuc00DONxN\nJJlwDwMHHHFWLeFnySSQDxxWhzk9nHk8nIx0xWraRkQOp7HvTbWDyxG2CM4ya0UUeZIvr3oK\nSGgkrG3TA/OnIP3rL7U0jdEuOADQ+77QHI+Uj86ChAf3f0NPzlwfamRhtrHGApPNOycqx55x\n9aQAhPlspGACalQ/IMYPApuPmcY9SKfbgvHktjnGKAIpVGxW9DTcDziD3FOkGQy+nNMY8B/f\nGKYCIPkZe26nd1PGPWl5Em0DG7p70mAqMg/hPSgBwB80IR8rdaaWYwbQQNp9Kc5G9JOfl/Wk\nVf3jjpnkUADHDRsBkYxSJkeYuM9+aCcxH1RqcyhZEbP3xQAybJhUj15rOv4VbbtHrWjwd64P\nAqnOCIFYD7pxQSzJtmSGZoVzu/iY9ua6iGcMYiOc+tctf5inJ43Mf0rW0idntyzMPlPGTzip\nkrlQdmbLgFm5xmi2kOwDoVPX2oD7gjdj1pqDZK6+vIrnaOtM0t43ROAct61Eg+SVD1qu8pEY\nJz8lTxyoXQngPSGUb+MmAMAMg4OB0rKSM210R95X5JNb82DvjxnvWXeIHUMq4I4Jz2q4voKS\n6llbiE25TIyDjip40RpUkwCOlY6xLHKSQSGHTPep4bt4oyBwVNNok344Yi0imJSSvZaiFtGI\ng7HBDelVRqjCSJwg54Y0R6jueaNxxtJFTYdmXZbWCS7RtuAw7cZqvLpkSxzL5hDckD8Kgk1R\ntkbLg7e9V59RlkkLgbQ3tRYNUNuUeERukrHHHNVI5rgzyfxDtTW3zKysx4PStG3gQGFwc4AD\nbvXvVaIWrCCV2tlLDJU1opzIpPAI6U1I497qq8EDFO4MROfmj4NQWJKSFkQj8aqgco3Zalmk\nIZSDlCOajhUNGyenINSMer4nYDjeKhc/6OfVWpxPEbHqODSSnDvwCpXIwapIlmbqMwXZ6MOT\nXOzZYsFOV9q072YONndec1VSKNpFBAz/ABCuiJyTdyvbWu9olYELk1t2luqzlgGVSO4p9nbq\nImQKFB5565q3sEccbg/gKolIRVBiJPG04zUh+WVX6q3XFAcB3U/dPalyDGFI5WgoYgJ8xQOM\n8UkjYjjY4GDTnbDggjBGOnembBIjx8jvmkMQO288Y3dqdndEB/cNNdfkVk6g4JpyqFkCschh\nSAnBJkjIxzxTC5RmU8c0I48nGOU7jtSsxJBwW46gZpiEmY+aG/hbrTAeWQjk09wGDqeGXvTN\nxZUccA5+tMAyPKR/7vHvTgNsnTORTSmG2qM56UKDsYnqhwaAFCHaYx1HNISMRNgjbncRSk4k\nVxxkU0DcGjyd3WgB3ClkyeRwfWm5+RlIyVpdwZNwPI4NOH+sx/C4oASVtpVh3AqtLyrIerci\nrAwYip7dKryYHluaEJmNqC7ovMxyhxiotJl8rUBkYVgcjNX7xAGkRuQRkYrOhyZQyDkHmgk6\nq2J8tlJ+7z9alJDmN89OuDWfZz/OpZuNuK0IyGDq3TGRWElZnVBkoYljGVzuGR6UQHMIz1Bq\nBWJiRgSGHFTqcSn0aoZomTOdxWXHB4qm0efNiHXGRVuIhojGf4TxUbIWIkBwc4NIopyx/KCA\ncjApFtC8g5AD1fWH9+VIzu5FOjBEY9VJFO4ygtiRG2cfKeM0ht3cI6gY6GtRiAy9CCBninJF\nuWSIAALyDincLoyv7OZHZCflxmongJQgLnDHrWwWDQqcfOOtRPDmXPG1x2obDQz47ULKCcfN\nwavQxgI645U5FNSIlMc7lIqcsFIJ6Hg1G4EmcKj+pwaZkrIVIGHFO6b4z16pioHffFnPKcGm\nIryHcpz1RsVLuHmBgMAiozkzFgflalRtyGM8MozTsTcRskyIOvaqN85+zbgQpXg4NTzzFGVw\neMjP5VmXIdkcAkI46HvWkVqZTloZbMxkbnrV+33fK6orOuQOKoxxtvx3HFattGS4weGrY5i3\nAxxE+PlYfrU2OWTPuKjjAETxqSMdCT/KpM5RX5/GgpCFgVRjgEEAml4WTGco9IBjKkDB6UAE\nxqMH5TQMcFzAw4BU8ZPagDBDZ6jB+tNbh1bPBFOHzDyjwV5zQAmOXT8RRjCLJ/d60rODiQfe\nxTY1w+1v4+aBknWZuwb04pbcAR4J7mmDJHPBXNIyZCkNjikIkY7J1ZT8u3g/WolwUZQcEKdo\nqSQfIy91qPPKyAc4Ix7UxjVLsEAHI4NOHEhU9GP50oAEpGPvdKaA3l/d5QkUCDG7cMcr0x2o\nDHekg+lKSN6behByaaoAYjNADgMFl455pP8Almu3l17UrHCbjjK4BoPEhI6NQArYRw3UHioJ\nTjcoGT2qbG7crclTkUMw/wBZtGW4oAzbtN8RfHK8FqgtbWRJGLYUHuT1q7dIPPChflYZwOhN\nX1tkFghIIkQ5Pek2JIy442jlc5B244q8rERpLn5eAcGluIVMSThcFuGPrVeCQK7xN36VD1NV\noXlYCV4weD92pA2YwMfc5NVYz91v7vBqzDIQVI+VWGCDWTRaZOrAMjDgMKcvUx/zqOJRtKAd\nDnNT5XG8DPqak1QpPyK/deKcuBP6o/SkIG4pj5SMilBzHtXjyzSKE27kYZwVOMVKGKtHIR8u\nMH60zJ3JIP4utOw214sn1FUSJ5TM78YB6ZpGU+VwOUOKd92NXzlkOD70pAD4HRh1oAYRiXcR\ngFcVEBtQgjLdcVIy7o2UnJHSmyHARscdDSGRMx2KwHIGKhOMnH8QqX+LZn73SqzHdHx95D0p\niY4YCDnlDimO2H3AAA9eaHYD5gOGqrPKAGjPLfw1SVzNuxFNLvdogGIz1AqQQie2VuQFPpTY\nwyoJHZS2eQK1rW2ypGcK2cAdDV3sZ7nPzQbLjk4yM9KngQsAAeVOasXsG5SmOVP6UqIsYR16\nHg4rRGbQDIMeBTwvJU8g5IJpyrwy4G4/d5poB+93U4xTEJgsiEZ460pGST2alGFY7eA3akG7\nG09qBjQMRSR7slTn8Kc2d6P0A4NLkDLAAbhhjSBAGK5wDyKABfldhjr70feTceChINDbhEHH\nXOKcCA2SR83b3oAVVIIB/iHWoydh2kn86cpJUgjlTxQY/OO/OO2KAHykF1fHGMEUxF52H04p\n7A7mUEHHNRkkAMM5X0oADkgOvO3tStkOCRww6UAgEHs1C7uVIwcnBoGJjgrSdsing7gj8cE7\nhTg6q5GOG6GgQzHzdPvcUFeCvdec0Fx8y8gqeKUkk7xgjv7UAJu5VweTwaTYC2z+FsmgYyQD\nweRS/NtG0cjrQMhjjMkg44TjPtW35avHG6DKMMGsyPcsiybSVbg1r6WN0Ulu3DD5lqZAjOeM\nKZYWUf7NZtzC0Z8xD93qMVvXtsSfNx9z71U5EVpFBGY2BzUbGtrooI4D88hxn2FSxSB0KHJI\n5BB6VVeB4m2k/dOV+lSxyhMPjhT81DRK3LiuSY5Np9D7VZB2Er1VgMVWhYbmVuA/T+lTqrNF\nuBGUIzj0rKxsmSCQmNsNlozz9KcrFpBJn5W6j1prKsbB+drDBFPRMEr6UrF3JVA5T05BpNxw\nHHBHBpuS0YK/eXqakAw/P3WHSmIHP70AD5ZOfxpBkllOAV5BxTwh2berL0prHGyXGQeGFMBh\nfBSTA298Uxky7LnjqKfs2lk7Ece1QMzeXjOGBpDIJj+4A6MhqJ3Cvu/vCpZHOQ5Hykc1QuJV\niLqzZPaqSuRKVhZZNqyJ1HUVGkbSukjHBBIIpIlaYb8YGcVdAji3bzjPIxzV7GW4zyVO2Lb8\nprftIj/Z4UDbJGOMjtWXp1tJPOzuudpBUf1rcuDtPmKOo5pIGYtxFvfzNuQRg1VaAxMyEdeV\nrWjCq7wyDOeVxUhgjnt9yr86EA5q0SzCJcwCRR8ynpQQA5OPv+/Sti40zD7k5V/T1rPezlXc\njcFTxVkFfadpUZ3LyKVSwZXPGeMU9gM7889DTQBu29c8igA2gBlY57ikzlV45jOTTkO+P5hy\no20HAfI6OMdaAEY7mLD7pA4pAhIwP4eadsYFl4ODRuGFccc4NACjkrJ68GmShlc46VKF2sUz\nlTzSrtCgMRkUDGSH5NwIBU4pudrkj7rDpSytsYkZPmGo8FgwJ5XJoEOQbkKNwy5xSuTtWTuv\nBFJklVcAEHrStw23selAAQV+X1pAGxt6le/tRjaBk/MD19aRidwYE89cUDFJz845B4zRj9/5\nZIz1BqeGzeWRkztQ8j1qytsvlgjll60gsUliaZQAuCp61ZS1QOHbo3aryRqkg+XHmdPapIbd\nV8wOct1HtU3KKJgVVeHHHUe1SQMY2Vk/h4J9KklAkZHHGCQaApRyoHD8/jQFjQZFdWCkMkg4\nNY88LRb4ySNnT6VpxDFsYzwVPH0FLdRBwsuOo2nFJq4J2Zz92qyBWA56VlljbzvbsOX5JroL\ni2KSNHjAblTWfeW4lhIbgxnO7vUrQtq+oyBi8anneh7VcSYBg4bh+o7VjRTmG5JIODyRV+El\nwyg8DkUNBGRpqdyvGT06VYXBiEnG9eDWYkxAjcHnODVxLny5WG35H5FQalgKAwwMLJ+lKRuR\nkIG5eQajWUyJt7pzT/MAKOe/WgRITjy3A69aayjcyHgHkfWkLnc0Q6ckGq8k2FVmPzIcGgB8\nsgER/vKevqKpTON+8EFSMcU+WTdIj5yrg4rKvLjy/NgB68g9aaVwcrIkmm2rJEOecio4oPOA\nnPPOMGo7RNyLcSsSB2Hf61ooqKSu3CyAH6VbfKZrXcI1RHxjhlpI4Xuj5YHzBhSKHnZo4Ryp\nxk9hW7Y2wgRZQPv8N71O4XSRNBELdUI5yME1K4B3xsDg8q1PCZMkY44ytNlBa3Vv4kOPwrSx\nlcy2BBSQjJQ4P0q6jDzCUjO2TmmXCqjgAcP2p6BzbsFGNnSl1KexKibkZOQVJIqKe1EixzLt\nz0bNTJJkQzevDVJtHmmI/dIzVEGJPpxSYx54YZU+tUZIZFj3gEtH7V0UsW+DOMtGcCq8sKrJ\nu6Bhg07jMJhs5OQG7CkKZLKONvI961WiQh0YDI6cVUeDKLKMhhxxRcdirkrsk684NKQBJ5e3\ngjINWfsDh9vO2QfLUTQyhCxB3KcfhRcVhgPyhWJyvejZ5gDU9x5cgLAcjFRF/JJXAPOaAEl3\nOrAcbPamgg4kzkkdKfISW3KM7xiprbT5plZWQJjkcdaYFdQWO1RzjhcVNDbSyQhtoLR8Y9a1\nYLCOJInJ+deGq9FCsbnAXEnpSuBjRacwkTcMqwzVqHTY0WRWT5zyOau7CIiCeYzS8t5cgGAO\nDSAi8oeWjouCDtNLFAFkw3AarCptZkOdp5GKickqV3ZYUhkL5IZP4ozUjAbUlwSDwaVYgZEl\nzhW61KE+V4/+BCiwXKAgJlZCMBjleak8sKvq6GidnCJIBkqcYAqSA/vCW4D0hkoRg6yE/K3G\nB60qpnzInBz1Wn4LI8XQqcikZgwjf8DTJK1xbiW33gYdTis65tfKmwwzHIOgrcC/Mw7OOKge\n38yBl/jjPFJq5UZWOUubTz0dAv7xPuVReWWyIfkoOCcfpXUXVkymOZOR/FgVnvaAySROuQ3P\n50ti7J7GeuoQO7oxO046fw1aivFdAokA8vrVKfTVMIIypUgDj86rNps6uAuSjk4xRypk3kje\nW5+YOu3a4xu9acZw3mKSOmR+Fc2lpfRlkwf3ZyPcmnSxagSjYbcRtbB4WjlQ+dm+12oVZN4y\no5Ge1QtfRLKxLgx9WPpWSun6hIxWQDLr61Lb6JI6OZJirdcDjIo5UHNJiS6g8oKR4O0/Lj09\nakt7InZcTHcW5ODVyCzhgwflJfqQKmWHlok9OO9JytsCi3qwjjX5ohwpycEU9Immi+X+E/pV\n22sWmCTSchSFPbitOK3jjnwoGxx+VJRbKckiCys47eUSbsiVelXUjGx4sYxyKAMxMnRozSkk\nFJPXg1olYxbuJuOxJBjIO1sGlIxIwz8rjIpQB5jJgYI/Wo3yYDzzGaoRSlORg8spq4mRskHC\nHqKihUNcl3GQ44qVQSskR/h6VJTHBRmSIqMfeWlYsYwwBynBpck7HHGODS4xIRuwr80yQVds\nhBI2SDjNQSxloivdTkfSpQMw89UPFOLAPGcfK/H0oApSRplZQcbuDT4bZdzITy3IqwEUrJHk\neooyAiSf3eCaB3Gbf3at3j460rQo033ciQc08Y88jja65FABMRH8S80AZ8mnKyOMYccjNZxt\nd4DMMnHNdCWO+KT+EjBqCVFRyCg/KgLlJIUCEbASp7CraxlGjlP8QwalRAkgG3hxTtu5XQ9V\n5pWC4Km13i9RkUZDRjnmNsEU7PEbd+hpMESSccOMimICMSKw6PQBnzIz06ikwSnpsNKzbWik\n6g8E0DGyN/o4cH7vFQxIGuCx4DipypJeLkE8g0wZVFH904pAOVQItmPu048bJPwNKT++DDGG\nFIFyJEzjHI9qYDdq+Yy4+8KrsGKnn7hqfOYo5e+cGl2hp2XOA4zSAcGG5JAchhilVOJIz1xk\nUxBmDaeQhyKkJw8cvqMGmIbjKRyf3TzTuPNyBwwoACyyJngjjNN58lDjJQ4pgN2AxzRk4xyK\nq3doHSOVMBgRmr5OLjOchxTFTcJYyOeopbgnYwprcxXDI2drDd+NU/L3RsAcsv6V0k6I8aSF\nckMRn2qhPYul0WUAiQZFZuJtGV9zLKbGjfqG4ahY+WXkDrT5ImVChGWB/KomcIqyNgg8VNiw\n3Y2yA5KVIGxLg8B+ahAV55EXJUjjHNTJbzSQIQp+U4yetIdwGW3Rpyw6CtW0s1RIZ3OS3XFS\n2dpHbyKxHzSLg5q3GMxvGewyKuMTGUuwJHiWROiMMgUIreTjqUNLnKROOdvBp+cTlf7y1oZi\nuQJ1wOHHNIFyskeeeopvPknOSVNOP30cdDTEM3ERJLx8vBpk5VJyp4Eg4NSKgIlQ9DzUfEnl\nuSp2nmkNAqhbbOMtGaeDh0cjl+DTukw7q4poXMTx90OaAHfxujdeooPzIrf3TikC/PHL2705\nQAzrnjqKBCHifplXFCj90y45U5x6Uh+aEN/dOKfwLgHPyuMUAJuA8uQDrwaUpnzUPQ8imgZi\ndCfumh3AMch78GgYg/1Cn+JTT84nBPSQUKMSug6YyKZkmDd0Ktj6YoANu9JEHAU5FPQB0DEZ\nNL/y2DZ4YUzd5ZKn1oAaeY4z/dOKd1kz2YUwDMbD3JpzHKxt64oAQcxOvPy0M3yxyY56YpwH\n7xh60h5hYHnaaBhjEjgn7w4pqDMHup6U4/6yI9qco/eyL7UCEJ/eI47ioyv7x1HrkCl5+zR5\nPKnFOHFyp9RQA370aN/dp4I+0pzkOKRFwskdJuHlQHkkUAG35JF96UkbIz6Hk0/rKR/eFR9Y\n5B/dJoAcMCUgD7wpi7jCynqrGnOD5kT/AMJ4NOXmSQeooAN2HjY85oUfNLH681GQDACf4T/W\npTkXatjg5oERn5rZSeq81ITtuVI6MKZj93IvpQx+WJvSmA0DKTL6ciobmYRxRzMduOKsAYum\nHqK5/wAQmd7GSNAwQN8xU0FLcivtWt/tcioMluB9KxJ7uRk+UEIjcD1pFlS3kRigbHb0qGSU\nyzTuifL2FKxbZKl/cRyiVGIyMYqVNdvLNiZDvDjIHpVGORjEMLyDkU2aYGRW4GPWiwmzs9H1\ngapZJIB86MFb8K28fvt398V5ppE15HdytbJI0BcAgDj3r0W3kZoYJWQrnsfQ0EEijCOoH3Tm\nlY7TC/vzTlBE8gx1FM625H9xqBEinErp2IyKavMP+4aVjiaNx0PFCcmVPU0AGdkyPn5SDTIU\nB85ccA8UkwzDH7GpUG24bH3SOKAGE/uo39Kco/0lh/fWm4xbOP7pqQnEkbeooAjz+6Yc5U9q\ncTho37EYoUfM6/Wmkj7Mh7q1ADkHzTIOcikIxDGfQgVJn/SOv3hUYGYWH91s/rTAc4/0gjtI\nKrwndBIhOSrVYc58t/Wo4UCzTr60hj8nMMh6EYNOXhpF7kcVFkm1X2NTHi6B9VoEMPMKnP3T\nSSqrPnJ5oUHyZAOTk1Kgyin2oGRKoMjKR70wkm2B9GqQcTk+opqj9247A0AOJxLGexWkUcyr\n+NK/3YqVBiaT6UCGE5VD708/8fH1XNRAgWoPoaef9ap55FAxmBskHoxNK/PlN3pyjLTA9Bn+\nVR/8s4z7j+dAyUE+dJ6EcUwf6jPoaf8A8tx/uGmYBikX0P8AKgQ4ZEsbHsKSMfvJR7/0oc48\ns05P9fIfX/CgQxjut0J7NTxkXPTg1GP+PRT71IeJY+RyKBjMAxSDPQ05s4iJPQUqE/vVprH9\nxH7NQHUcv+tceopjAG2Of4TUhH+k/UGmfeidfc0xCyn9/ER3FZ9/Bvt7lSSN3Xjp+FaMif6k\n56GoZjseUjklTx+FA0ebzozSMSxUqTknvzVx5Y4RsDBzjvVW6u2UGOOEH5jkscnrSC2lYh2X\nBPNMRXuJNsbOSMq33eeh9KQRpf3sVujMcnkgc9KjuFKhsMSR1z/Kum8HaeEAvJo8u5wpPYYx\nSA2tEsYLBZLeKNlBGSW65rQQyG25XGGwOasKgW5bjnFNQf6M+VOAc4P1pAS4/fr7impkxyg/\nWlfh4iKUZLzL7UAMIykLU8E+e3uKb/y7R0/GLgfSgCEg+Qxz90nn8amJxLEw6EYxUYx5c2Oz\nEU9+sZ9qAEA5lFBOY4j6MB+tKvEkp9RTG4tl9moAecm4wO6/1piqfsrgHOGqT/lsv+4aaOEm\nHoSf0oAV/vROO/FKAS8q49KQ5Kw5PXilX/XyH2FMBg/1Ke1O/wCXjPquaa3+oz6Gnt/rVI/u\nmkAyMfuZASMhjT3PzQn1psfzLMD0z/SlPMUJ7k0AKqnzJRwBjinRD5BzTR/x8MPaiIYTjgZN\nADTxKv8Auk0fwPSgfvj7LTCcQu3r/jQMGPEftT1x5z89qRx90Uq8SvQBGgzbfn/OnsD50fPa\nmDi3XryP61J1nQegoAQcGb3FMA228Y9x/OnDkynsRSP/AKmKgCT/AJbhfamKRiU9gTmnD/j6\nP+7mmA/uHb1zQIVxkR/WnL/rm9hSP/yxHrQrYmkPoKAGA5tTnIGcn25qRgDPGcUzGbcDsTUh\n/wBcvsKAEUje5NMxm2j/AN4H9achzHI3vihhhIloAd/y8gf7NRr/AKuT61IDm5P+7UZ4tmPq\n1AD35WMfSoJg7SShBn5cZP0qcj505pgHzynimBxE/h/UnLTLEmAQFO7FStoOreZsIRiVJUg1\n2BT9wgB+Yt3p5GLjKnGFNAzhIPCeoy3264KCBDljnlq7OK3WK1t40GAuABj3qYY8iXPOSf5U\n4jAhWkA5R/pZP+zTcgwPj1/rTl/18h9BTP8Al2J/z1oESNw8Q9KAf3kv5UN/x8KPamjgTt9f\n5UAJ1t0/On9bgewpnSKL35p4H+kM3YCgBv8Ayym9yf5Up6xj2qMHNuzep/rUrD99GPagAX/X\nP9B/OmMf9Hz6tSp9+U0hX9xGv97mgB+Mzge1N/gmb6j9KeDmfP8As5pn/Ls59TmgBW+5F+dO\nH+uf/dpsgy8I7Uq/62U+wpgMP/Hv2/GpG/1qj0FR/wDLtGPU1L1uM+gFIBijEcx9Sf5UH5Y4\nh70g4tpW9c05x/qV/GgBwH+lE+gpsTYTp3P86UH55SD7UsHEQpoBif65qjf/AI9W+tFFIY8/\n69PoaVADNJn2oooAYf8AUR/UU9f9d+FFFADF6PQfux0UUAOH+tk/D+VMP/Hv/wACoooAl/5e\nIh25pqf8tvxoooEMf/UQ/Wnj/j6b6UUUAMP/AB6y/wC/UjdYqKKYCr/rZfp/SmKP9HX/AHqK\nKAA/8fI+lC/dl/H+VFFIBG/1cP1py/8AHy3+6aKKAIh/x6N9TUx/10P+7RRQA1fvS/Shv9Sn\n1oooAf8A8vh/3ajU/uJP896KKAF/55U7/ltN/u0UUARr/wAe0f8AvVMf+Pxf900UUARL/qZf\nqf50r9IKKKAHp/x8N/u0zpAAOhNFFAEh/wCPhfpTAMeZj1oooEKf+WY+lKv+uk/3RRRQPoMH\n/Hv/AMCNSZJmTPpRRTAbH92WpIf9Sv0oooQM/9k="}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_1_AlertName", + "Values": [{"Value": "Birth Date Crosscheck"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_2_AlertName", + "Values": [{"Value": "Visible Pattern"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_3_AlertName", + "Values": [{"Value": "Birth Date Valid"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_4_AlertName", + "Values": [{"Value": "Document Classification"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_5_AlertName", + "Values": [{"Value": "Document Expired"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_6_AlertName", + "Values": [{"Value": "Expiration Date Valid"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_7_AlertName", + "Values": [{"Value": "Issue Date Valid"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_8_AlertName", + "Values": [{"Value": "Visible Pattern"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_9_AlertName", + "Values": [{"Value": "Visible Pattern"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_1_AuthenticationResult", + "Values": [ { + "Value": "Failed", + "Detail": "Compare the machine-readable birth date field to the human-readable birth date field." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_2_AuthenticationResult", + "Values": [ { + "Value": "Failed", + "Detail": "Verified the presence of a pattern on the visible image." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_3_AuthenticationResult", + "Values": [ { + "Value": "Passed", + "Detail": "Verified that the birth date is valid." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_4_AuthenticationResult", + "Values": [ { + "Value": "Passed", + "Detail": "Verified that the type of document is supported and is able to be fully authenticated." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_5_AuthenticationResult", + "Values": [ { + "Value": "Passed", + "Detail": "Checked if the document is expired." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_6_AuthenticationResult", + "Values": [ { + "Value": "Passed", + "Detail": "Verified that the expiration date is valid." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_7_AuthenticationResult", + "Values": [ { + "Value": "Passed", + "Detail": "Verified that the issue date is valid." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_8_AuthenticationResult", + "Values": [ { + "Value": "Passed", + "Detail": "Verified the presence of a pattern on the visible image." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_9_AuthenticationResult", + "Values": [ { + "Value": "Passed", + "Detail": "Verified the presence of a pattern on the visible image." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_2_Regions", + "Values": [{"Value": "Verify Layout 1"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_8_Regions", + "Values": [{"Value": "Visible Pattern"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_9_Regions", + "Values": [{"Value": "Name Registration Verify"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_FullName", + "Values": [{"Value": "LICENSE SAMPLE"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_Surname", + "Values": [{"Value": "SAMPLE"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_GivenName", + "Values": [{"Value": "LICENSE"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_FirstName", + "Values": [{"Value": "LICENSE"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_DOB_Year", + "Values": [{"Value": "Failed"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_DOB_Month", + "Values": [{"Value": "to OCR"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_DOB_Day", + "Values": [{"Value": "DOB"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_DocumentClassName", + "Values": [{"Value": "Drivers License"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_DocumentNumber", + "Values": [{"Value": "020000060"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_ExpirationDate_Year", + "Values": [{"Value": "2099"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_ExpirationDate_Month", + "Values": [{"Value": "5"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_xpirationDate_Day", + "Values": [{"Value": "5"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_IssuingStateCode", + "Values": [{"Value": "NY"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_IssuingStateName", + "Values": [{"Value": "New York"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_Address", + "Values": [{"Value": "123 ABC AVE
ANYTOWN NY
12345"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_AddressLine1", + "Values": [{"Value": "123 ABC AVE"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_AddressLine2", + "Values": [{"Value": "APT 3E"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_City", + "Values": [{"Value": "ANYTOWN"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_State", + "Values": [{"Value": "NY"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_PostalCode", + "Values": [{"Value": "12345"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_Sex", + "Values": [{"Value": "M"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_ControlNumber", + "Values": [{"Value": "6820051160"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_Height", + "Values": [{"Value": "5'08\""}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_IssueDate_Year", + "Values": [{"Value": "1997"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_IssueDate_Month", + "Values": [{"Value": "7"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_IssueDate_Day", + "Values": [{"Value": "15"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_LicenseClass", + "Values": [{"Value": "D"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_LicenseRestrictions", + "Values": [{"Value": "B"}] + }, + { + "Group": "PORTRAIT_MATCH_RESULT", + "Name": "FaceMatchResult", + "Values": [{"Value": "Fail"}] + }, + { + "Group": "PORTRAIT_MATCH_RESULT", + "Name": "FaceMatchScore", + "Values": [{"Value": "0"}] + }, + { + "Group": "PORTRAIT_MATCH_RESULT", + "Name": "FaceStatusCode", + "Values": [{"Value": "0"}] + }, + { + "Group": "PORTRAIT_MATCH_RESULT", + "Name": "FaceErrorMessage", + "Values": [{"Value": "Liveness: PoorQuality"}] + } + ] + }] +} diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json index e8c4250614e..0d53e00a114 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json @@ -471,4 +471,4 @@ } ] }] -} \ No newline at end of file +} diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index c32270c6e60..f76b7ea7fa3 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -502,7 +502,7 @@ expect(response.doc_auth_success?).to eq(false) expect(response.selfie_status).to eq(:not_processed) expect(response.attention_with_barcode?).to eq(false) - expect(response.pii_from_doc).to eq({}) + expect(response.pii_from_doc).to eq(nil) expect(response.doc_auth_success?).to eq(false) end diff --git a/spec/models/document_capture_session_spec.rb b/spec/models/document_capture_session_spec.rb index 07b4ac0d08c..5e67837cfa5 100644 --- a/spec/models/document_capture_session_spec.rb +++ b/spec/models/document_capture_session_spec.rb @@ -4,10 +4,23 @@ let(:doc_auth_response) do DocAuth::Response.new( success: true, - pii_from_doc: { + pii_from_doc: Pii::StateId.new( first_name: 'Testy', - last_name: 'Testerson', - }, + last_name: 'Testy', + middle_name: nil, + address1: '123 ABC AVE', + address2: nil, + city: 'ANYTOWN', + state: 'MD', + dob: '1986-07-01', + state_id_expiration: '2099-10-15', + state_id_issued: '2016-10-15', + state_id_jurisdiction: 'MD', + state_id_number: 'M555555555555', + state_id_type: 'drivers_license', + zipcode: '12345', + issuing_country_code: 'USA', + ), ) end @@ -55,7 +68,7 @@ result = record.load_result expect(result.success?).to eq(doc_auth_response.success?) - expect(result.pii).to eq(doc_auth_response.pii_from_doc.deep_symbolize_keys) + expect(result.pii).to eq(doc_auth_response.pii_from_doc.to_h.deep_symbolize_keys) end it 'returns nil if the previously stored result does not exist or expired' do diff --git a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb index 277f46e84ca..d1fcc4049c0 100644 --- a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb @@ -12,6 +12,9 @@ let(:doc_auth_success_with_face_match_fail) do instance_double(Faraday::Response, status: 200, body: LexisNexisFixtures.true_id_response_with_face_match_fail) end + let(:success_with_failed_to_ocr_dob) do + instance_double(Faraday::Response, status: 200, body: LexisNexisFixtures.true_id_response_failed_to_ocr_dob) + end let(:failure_response_face_match_fail) do instance_double(Faraday::Response, status: 200, body: LexisNexisFixtures.true_id_response_with_face_match_fail) end @@ -120,22 +123,25 @@ expect(extra_attributes).to have_key(:reference) end it 'has PII data' do - # This is the minimum expected by doc_pii_form in the core IDP - minimum_expected_hash = { + expected_state_id_pii = Pii::StateId.new( first_name: 'DAVID', last_name: 'SAMPLE', - dob: '1986-07-01', + middle_name: 'LICENSE', + address1: '123 ABC AVE', + address2: 'APT 3E', + city: 'ANYTOWN', state: 'MD', + dob: '1986-07-01', + state_id_expiration: '2099-10-15', + state_id_issued: '2016-10-15', + state_id_jurisdiction: 'MD', + state_id_number: 'M555555555555', state_id_type: 'drivers_license', - } + zipcode: '12345', + issuing_country_code: 'USA', + ) - expect(response.pii_from_doc).to include(minimum_expected_hash) - end - it 'includes expiration' do - expect(response.pii_from_doc).to include(state_id_expiration: '2099-10-15') - end - it 'includes issued date' do - expect(response.pii_from_doc).to include(state_id_issued: '2016-10-15') + expect(response.pii_from_doc).to eq(expected_state_id_pii) end it 'excludes pii fields from logging' do @@ -213,7 +219,7 @@ end it 'notes that address line 2 was present' do - expect(response.pii_from_doc).to include(address2: 'APT 3E') + expect(response.pii_from_doc.address2).to eq('APT 3E') expect(response.to_h).to include(address_line2_present: true) end @@ -252,7 +258,7 @@ let(:response) { described_class.new(success_response_no_line2, config) } it 'notes that address line 2 was not present' do - expect(response.pii_from_doc[:address2]).to be_nil + expect(response.pii_from_doc.address2).to be_nil expect(response.to_h).to include(address_line2_present: false) end end @@ -316,22 +322,25 @@ def get_decision_product(resp) expect(extra_attributes).not_to be_empty end it 'has PII data' do - # This is the minimum expected by doc_pii_form in the core IDP - minimum_expected_hash = { + expected_state_id_pii = Pii::StateId.new( first_name: 'DAVID', last_name: 'SAMPLE', - dob: '1986-10-13', + middle_name: 'LICENSE', + address1: '123 ABC AVE', + address2: nil, + city: 'ANYTOWN', state: 'MD', + dob: '1986-10-13', + state_id_expiration: '2099-10-15', + state_id_issued: '2016-10-15', + state_id_jurisdiction: 'MD', + state_id_number: 'M555555555555', state_id_type: 'drivers_license', - } + zipcode: '12345', + issuing_country_code: nil, + ) - expect(response.pii_from_doc).to include(minimum_expected_hash) - end - it 'includes expiration' do - expect(response.pii_from_doc).to include(state_id_expiration: '2099-10-15') - end - it 'includes issued date' do - expect(response.pii_from_doc).to include(state_id_issued: '2016-10-15') + expect(response.pii_from_doc).to eq(expected_state_id_pii) end end @@ -541,12 +550,10 @@ def get_decision_product(resp) end context 'when the dob is incorrectly parsed' do - let(:response) { described_class.new(success_response, config) } - let(:bad_pii) { { dob_year: 'OCR', dob_month: 'failed', dob_day: 'to parse' } } + let(:response) { described_class.new(success_with_failed_to_ocr_dob, config) } it 'does not throw an exception when getting pii from doc' do - allow(response).to receive(:pii).and_return(bad_pii) - expect { response.pii_from_doc }.not_to raise_error + expect(response.pii_from_doc.dob).to be_nil end end diff --git a/spec/support/lexis_nexis_fixtures.rb b/spec/support/lexis_nexis_fixtures.rb index c9782428861..198fbb33942 100644 --- a/spec/support/lexis_nexis_fixtures.rb +++ b/spec/support/lexis_nexis_fixtures.rb @@ -202,6 +202,10 @@ def true_id_response_failure_tampering read_fixture_file_at_path('true_id/true_id_response_tampering_failure.json') end + def true_id_response_failed_to_ocr_dob + read_fixture_file_at_path('true_id/true_id_response_failed_to_ocr_dob.json') + end + private def read_fixture_file_at_path(filepath) From a46d620f4d9cb3f557dd983620a1a8bd7e646a3f Mon Sep 17 00:00:00 2001 From: dawei-nava <130466753+dawei-nava@users.noreply.github.com> Date: Thu, 25 Apr 2024 15:32:52 -0400 Subject: [PATCH 06/14] LG-12176: register and emit idv_sdk_selfie_image_taken event. (#10497) * LG-12176: register and emit idv_sdk_selfie_image_taken event. changelog: Internal, Doc Auth, Analytics event for selfie image taken. * LG-12176: linter. * LG-12176: test. --- app/controllers/frontend_log_controller.rb | 1 + .../components/acuant-capture.tsx | 4 ++++ app/services/analytics_events.rb | 19 +++++++++++++++ .../components/acuant-capture-spec.jsx | 23 ++++++++++++++++++- 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/controllers/frontend_log_controller.rb b/app/controllers/frontend_log_controller.rb index 43bf2f88d05..24e120c69f3 100644 --- a/app/controllers/frontend_log_controller.rb +++ b/app/controllers/frontend_log_controller.rb @@ -54,6 +54,7 @@ class FrontendLogController < ApplicationController idv_sdk_selfie_image_capture_initialized idv_sdk_selfie_image_capture_opened idv_sdk_selfie_image_re_taken + idv_sdk_selfie_image_taken idv_selfie_image_added idv_selfie_image_clicked phone_input_country_changed diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index fbd9deee175..6bc85b80884 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -713,6 +713,10 @@ function AcuantCapture( function onSelfieTaken() { selfieAttempts.current += 1; + trackEvent('idv_sdk_selfie_image_taken', { + captureAttempts, + selfie_attempts: selfieAttempts.current, + }); } function onImageCaptureInitialized() { diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 0f31d186ccf..8bc7a62676f 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -3301,6 +3301,25 @@ def idv_sdk_selfie_image_re_taken( **extra, ) end + + # User opened the SDK to take a selfie + # @param [String] acuant_version + # @param [Integer] captureAttempts number of attempts to capture / upload an image + # @param [Integer] selfie_attempts number of selfie captured by SDK + def idv_sdk_selfie_image_taken( + acuant_version:, + captureAttempts: nil, + selfie_attempts: nil, + **extra + ) + track_event( + :idv_sdk_selfie_image_taken, + acuant_version: acuant_version, + captureAttempts: captureAttempts, + selfie_attempts: selfie_attempts, + **extra, + ) + end # rubocop:enable Naming/VariableName,Naming/MethodParameterName # User took a selfie image with the SDK, or uploaded a selfie using the file picker diff --git a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx index a6cb3369a3a..d42618f43af 100644 --- a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx @@ -1234,7 +1234,6 @@ describe('document-capture/components/acuant-capture', () => { // This allows us to test everything about that callback -except- the Acuant SDK parts. initialize({ selfieStart: sinon.stub().callsFake((callbacks) => { - callbacks.onPhotoTaken(); callbacks.onPhotoRetake(); }), }); @@ -1250,6 +1249,28 @@ describe('document-capture/components/acuant-capture', () => { ); }); + it('calls trackEvent from onSelfieTake', () => { + // In real use the `start` method opens the Acuant SDK full screen selfie capture window. + // Because we can't do that in test (AcuantSDK does not allow), this doesn't attempt to load + // the SDK. Instead, it simply calls the callback that happens when a photo is captured. + // This allows us to test everything about that callback -except- the Acuant SDK parts. + initialize({ + selfieStart: sinon.stub().callsFake((callbacks) => { + callbacks.onPhotoTaken(); + }), + }); + + expect(trackEvent).to.be.calledWith('idv_selfie_image_clicked'); + expect(trackEvent).to.be.calledWith('IdV: Acuant SDK loaded'); + expect(trackEvent).to.be.calledWith( + 'idv_sdk_selfie_image_taken', + sinon.match({ + captureAttempts: sinon.match.number, + selfie_attempts: sinon.match.number, + }), + ); + }); + it('calls trackEvent from onSelfieCaptureFailure', () => { const errorHash = { code: 1, message: 'Camera permission not granted' }; From 7ff6163d8e47de62005a886df5ae8f26bdd5c6ea Mon Sep 17 00:00:00 2001 From: Zach Margolis Date: Thu, 25 Apr 2024 13:46:31 -0700 Subject: [PATCH 07/14] Move IdentityConfig code into Identity::Hostdata (#10476) * Move IdentityConfig logic into Identity::Hostdata - Allows for better sharing across codebases - Keeps "IdentityConfig.store" around to forward, which allows keeping old syntax for now * Preserve config values so they don't get reset between specs * Remove redundant logger assignment * Use tagged version of gem changelog: Internal, Source code, Use shared logic for accessing config data --------- Co-authored-by: Davida Marion --- Gemfile | 2 +- Gemfile.lock | 9 +- config/application.rb | 41 ++++---- .../unused_identity_config_keys.rb | 7 +- lib/identity_config.rb | 98 ++----------------- lib/tasks/check_for_pending_migrations.rake | 4 +- spec/lib/deploy/activate_spec.rb | 10 ++ spec/lib/identity_config_spec.rb | 44 +++------ 8 files changed, 67 insertions(+), 148 deletions(-) diff --git a/Gemfile b/Gemfile index bc4a4125569..765860ffcfc 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,7 @@ gem 'faraday-retry' gem 'foundation_emails' gem 'good_job', '~> 3.0' gem 'http_accept_language' -gem 'identity-hostdata', github: '18F/identity-hostdata', tag: 'v3.4.3' +gem 'identity-hostdata', github: '18F/identity-hostdata', tag: 'v4.0.0' gem 'identity-logging', github: '18F/identity-logging', tag: 'v0.1.0' gem 'identity_validations', github: '18F/identity-validations', tag: 'v0.7.2' gem 'jsbundling-rails', '~> 1.1.2' diff --git a/Gemfile.lock b/Gemfile.lock index 67e880503f4..64fd107137c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,12 @@ GIT remote: https://github.com/18F/identity-hostdata.git - revision: 42027a05a3827177d473a0f2d998771011fc4fd6 - tag: v3.4.3 + revision: 9574e05398833c531f450c3da99a6afde4ce68fc + tag: v4.0.0 specs: - identity-hostdata (3.4.3) + identity-hostdata (4.0.0) activesupport (>= 6.1, < 8) aws-sdk-s3 (~> 1.8) + redacted_struct (>= 2.0) GIT remote: https://github.com/18F/identity-logging.git @@ -181,7 +182,7 @@ GEM aws-sdk-pinpointsmsvoice (1.29.0) aws-sdk-core (~> 3, >= 3.122.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.132.0) + aws-sdk-s3 (1.132.1) aws-sdk-core (~> 3, >= 3.179.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) diff --git a/config/application.rb b/config/application.rb index f899203330b..c822a965720 100644 --- a/config/application.rb +++ b/config/application.rb @@ -32,24 +32,23 @@ class Application < Rails::Application Identity::Hostdata.logger.level = log_level end - configuration = Identity::Hostdata::ConfigReader.new( + Identity::Hostdata.load_config!( app_root: Rails.root, - logger: Identity::Hostdata.logger, - ).read_configuration( - Rails.env, write_copy_to: Rails.root.join('tmp', 'application.yml') + rails_env: Rails.env, + write_copy_to: Rails.root.join('tmp', 'application.yml'), + &IdentityConfig::BUILDER ) - IdentityConfig.build_store(configuration) config.asset_sources = AssetSources.new( manifest_path: Rails.public_path.join('packs', 'manifest.json'), cache_manifest: Rails.env.production? || Rails.env.test?, - i18n_locales: IdentityConfig.store.available_locales, + i18n_locales: Identity::Hostdata.config.available_locales, ) console do if ENV['ALLOW_CONSOLE_DB_WRITE_ACCESS'] != 'true' && - IdentityConfig.store.database_readonly_username.present? && - IdentityConfig.store.database_readonly_password.present? + Identity::Hostdata.config.database_readonly_username.present? && + Identity::Hostdata.config.database_readonly_password.present? warn <<-EOS.squish WARNING: Loading database a configuration with the readonly database user. If you wish to make changes to records in the database set @@ -93,11 +92,11 @@ class Application < Rails::Application config.good_job.execution_mode = :external config.good_job.poll_interval = 5 config.good_job.enable_cron = true - config.good_job.max_threads = IdentityConfig.store.good_job_max_threads - config.good_job.queues = IdentityConfig.store.good_job_queues + config.good_job.max_threads = Identity::Hostdata.config.good_job_max_threads + config.good_job.queues = Identity::Hostdata.config.good_job_queues config.good_job.preserve_job_records = false config.good_job.enable_listen_notify = false - config.good_job.queue_select_limit = IdentityConfig.store.good_job_queue_select_limit + config.good_job.queue_select_limit = Identity::Hostdata.config.good_job_queue_select_limit # see config/initializers/job_configurations.rb for cron schedule includes_star_queue = config.good_job.queues.split(';').any? do |name_threads| @@ -113,18 +112,18 @@ class Application < Rails::Application config.time_zone = 'UTC' config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{yml}')] - config.i18n.available_locales = IdentityConfig.store.available_locales + config.i18n.available_locales = Identity::Hostdata.config.available_locales config.i18n.default_locale = :en config.action_controller.per_form_csrf_tokens = true config.action_view.frozen_string_literal = true - routes.default_url_options[:host] = IdentityConfig.store.domain_name + routes.default_url_options[:host] = Identity::Hostdata.config.domain_name config.action_mailer.default_options = { from: Mail::Address.new.tap do |mail| - mail.address = IdentityConfig.store.email_from - mail.display_name = IdentityConfig.store.email_from_display_name + mail.address = Identity::Hostdata.config.email_from + mail.display_name = Identity::Hostdata.config.email_from_display_name end.to_s, } config.action_mailer.observers = %w[EmailDeliveryObserver] @@ -137,7 +136,7 @@ class Application < Rails::Application config.middleware.use Utf8Sanitizer require 'secure_cookies' config.middleware.insert_after ActionDispatch::Static, SecureCookies - config.middleware.use VersionHeaders if IdentityConfig.store.version_headers_enabled + config.middleware.use VersionHeaders if Identity::Hostdata.config.version_headers_enabled config.middleware.insert_before 0, Rack::Cors do allow do @@ -157,20 +156,20 @@ class Application < Rails::Application origins IdentityCors.allowed_origins_static_sites resource '/api/analytics-events', headers: :any, methods: [:get] resource '/api/country-support', headers: :any, methods: [:get] - if IdentityConfig.store.in_person_public_address_search_enabled + if Identity::Hostdata.config.in_person_public_address_search_enabled resource '/api/usps_locations', headers: :any, methods: %i[post options] end end end - if !IdentityConfig.store.enable_rate_limiting + if !Identity::Hostdata.config.enable_rate_limiting # Rack::Attack auto-includes itself as a Railtie, so we need to # explicitly remove it when we want to disable it config.middleware.delete Rack::Attack end - config.view_component.show_previews = IdentityConfig.store.component_previews_enabled - if IdentityConfig.store.component_previews_enabled + config.view_component.show_previews = Identity::Hostdata.config.component_previews_enabled + if Identity::Hostdata.config.component_previews_enabled require 'lookbook' config.view_component.preview_controller = 'ComponentPreviewController' @@ -179,7 +178,7 @@ class Application < Rails::Application config.lookbook.auto_refresh = false config.lookbook.project_name = "#{APP_NAME} Component Previews" config.lookbook.ui_theme = 'blue' - if IdentityConfig.store.component_previews_embed_frame_ancestors.present? + if Identity::Hostdata.config.component_previews_embed_frame_ancestors.present? # so we can embed a lookbook component into the dev docs config.lookbook.preview_embeds.policy = 'ALLOWALL' # lookbook strips out CSP, this brings it back so we aren't so permissive diff --git a/config/initializers/unused_identity_config_keys.rb b/config/initializers/unused_identity_config_keys.rb index bfcfd64c62c..751747e8c69 100644 --- a/config/initializers/unused_identity_config_keys.rb +++ b/config/initializers/unused_identity_config_keys.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true -if IdentityConfig.unused_keys.present? - Rails.logger.warn({ name: 'unused_identity_config_keys', keys: IdentityConfig.unused_keys }) +if Identity::Hostdata.config_builder.unused_keys.present? + Rails.logger.warn( + { name: 'unused_identity_config_keys', + keys: Identity::Hostdata.config_builder.unused_keys }, + ) end diff --git a/lib/identity_config.rb b/lib/identity_config.rb index c8c1f9b6c33..5dee8f89ef1 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -1,93 +1,20 @@ # frozen_string_literal: true -require 'csv' - -class IdentityConfig +module IdentityConfig GIT_SHA = `git rev-parse --short=8 HEAD`.chomp.freeze GIT_TAG = `git tag --points-at HEAD`.chomp.split("\n").first.freeze GIT_BRANCH = `git rev-parse --abbrev-ref HEAD`.chomp.freeze VENDOR_STATUS_OPTIONS = %i[operational partial_outage full_outage].freeze - class << self - attr_reader :store, :key_types, :unused_keys - end - - CONVERTERS = { - # Allows loading a string configuration from a system environment variable - # ex: To read DATABASE_HOST from system environment for the database_host key - # database_host: ['env', 'DATABASE_HOST'] - # To use a string value directly, you can specify a string explicitly: - # database_host: 'localhost' - string: proc do |value| - if value.is_a?(Array) && value.length == 2 && value.first == 'env' - ENV.fetch(value[1]) - elsif value.is_a?(String) - value - else - raise 'invalid system environment configuration value' - end - end, - symbol: proc { |value| value.to_sym }, - comma_separated_string_list: proc do |value| - CSV.parse_line(value).to_a - end, - integer: proc do |value| - Integer(value) - end, - float: proc do |value| - Float(value) - end, - json: proc do |value, options: {}| - JSON.parse(value, symbolize_names: options[:symbolize_names]) - end, - boolean: proc do |value| - case value - when 'true', true - true - when 'false', false - false - else - raise 'invalid boolean value' - end - end, - date: proc { |value| Date.parse(value) if value }, - timestamp: proc do |value| - # When the store is built `Time.zone` is not set resulting in a NoMethodError - # if Time.zone.parse is called - # - # rubocop:disable Rails/TimeZone - Time.parse(value) - # rubocop:enable Rails/TimeZone - end, - }.freeze - - attr_reader :key_types - - def initialize(read_env) - @read_env = read_env - @written_env = {} - @key_types = {} - end - - def add(key, type: :string, allow_nil: false, enum: nil, options: {}) - value = @read_env[key] - - @key_types[key] = type - - converted_value = CONVERTERS.fetch(type).call(value, options: options) if !value.nil? - raise "#{key} is required but is not present" if converted_value.nil? && !allow_nil - if enum && !(enum.include?(converted_value) || (converted_value.nil? && allow_nil)) - raise "unexpected #{key}: #{value}, expected one of #{enum}" - end - - @written_env[key] = converted_value.freeze - @written_env + # Shorthand to allow using old syntax to access configs, minimizes merge conflicts + # while migrating to newer syntax + def self.store + Identity::Hostdata.config end - attr_reader :written_env - - def self.build_store(config_map) + # rubocop:disable Metrics/BlockLength + BUILDER = proc do |config| # ______________________________________ # / Adding something new in here? Please \ # \ keep methods sorted alphabetically. / @@ -100,8 +27,6 @@ def self.build_store(config_map) # ./ / /\ \ | \ \ \ \ # / / \ \ | |\ \ \7 # " " " " - - config = IdentityConfig.new(config_map) config.add(:aamva_auth_request_timeout, type: :float) config.add(:aamva_auth_url, type: :string) config.add(:aamva_cert_enabled, type: :boolean) @@ -514,11 +439,6 @@ def self.build_store(config_map) config.add(:vtm_url) config.add(:weekly_auth_funnel_report_config, type: :json) config.add(:x509_presented_hash_attribute_requested_issuers, type: :json) - - @key_types = config.key_types.freeze - @unused_keys = (config_map.keys - config.written_env.keys).freeze - config.written_env.freeze - @store = RedactedStruct.new('IdentityConfig', *config.written_env.keys, keyword_init: true). - new(**config.written_env) - end + end.freeze + # rubocop:enable Metrics/BlockLength end diff --git a/lib/tasks/check_for_pending_migrations.rake b/lib/tasks/check_for_pending_migrations.rake index 279798d8c20..c038c71401e 100644 --- a/lib/tasks/check_for_pending_migrations.rake +++ b/lib/tasks/check_for_pending_migrations.rake @@ -7,7 +7,9 @@ namespace :db do if Identity::Hostdata.instance_role == 'migration' warn('Skipping pending migration check on migration instance') - elsif Identity::Hostdata.config.dig(:default_attributes, :login_dot_gov, :idp_run_migrations) + elsif Identity::Hostdata.host_config.dig( + :default_attributes, :login_dot_gov, :idp_run_migrations + ) warn('Skipping pending migration check, idp_run_migrations=true') else ActiveRecord::Migration.check_pending!(ActiveRecord::Base.connection) diff --git a/spec/lib/deploy/activate_spec.rb b/spec/lib/deploy/activate_spec.rb index 02b53c49247..4a7a8ad244b 100644 --- a/spec/lib/deploy/activate_spec.rb +++ b/spec/lib/deploy/activate_spec.rb @@ -5,12 +5,22 @@ let(:root) { @root } around(:each) do |ex| + snapshot = Identity::Hostdata.instance_variables.index_with do |name| + Identity::Hostdata.instance_variable_get(name) + end + Identity::Hostdata.reset! Dir.mktmpdir do |dir| @root = dir ex.run end + ensure + Identity::Hostdata.reset! # clear any new variables set by the specs + + snapshot.each do |name, value| + Identity::Hostdata.instance_variable_set(name, value) + end end let(:logger) { Logger.new('/dev/null') } diff --git a/spec/lib/identity_config_spec.rb b/spec/lib/identity_config_spec.rb index c66939cf80b..d6f18a25636 100644 --- a/spec/lib/identity_config_spec.rb +++ b/spec/lib/identity_config_spec.rb @@ -2,9 +2,11 @@ RSpec.describe IdentityConfig do describe '.key_types' do + subject(:key_types) { Identity::Hostdata.config_builder.key_types } + it 'has all _enabled keys as booleans' do aggregate_failures do - IdentityConfig.key_types.select { |key, _type| key.to_s.end_with?('_enabled') }. + key_types.select { |key, _type| key.to_s.end_with?('_enabled') }. each do |key, type| expect(type).to eq(:boolean), "expected #{key} to be a boolean" end @@ -13,7 +15,7 @@ it 'has all _at keys as timestamps' do aggregate_failures do - IdentityConfig.key_types.select { |key, _type| key.to_s.end_with?('_at') }. + key_types.select { |key, _type| key.to_s.end_with?('_at') }. each do |key, type| expect(type).to eq(:timestamp), "expected #{key} to be a timestamp" end @@ -22,7 +24,7 @@ it 'has all _timeout keys as numbers' do aggregate_failures do - IdentityConfig.key_types.select { |key, _type| key.to_s.end_with?('_timeout') }. + key_types.select { |key, _type| key.to_s.end_with?('_timeout') }. each do |key, type| expect(type).to eq(:float).or(eq(:integer)), "expected #{key} to be a number" end @@ -30,27 +32,9 @@ end end - describe '::CONVERTERS' do - describe 'comma_separated_string_list' do - it 'respects double-quotes for embedded commas' do - config = IdentityConfig.new({ csv_value: 'one,two,"three,four"' }) - config.add(:csv_value, type: :comma_separated_string_list) - - expect(config.written_env).to eq(csv_value: ['one', 'two', 'three,four']) - end - - it 'parses empty value as empty array' do - config = IdentityConfig.new({ csv_value: '' }) - config.add(:csv_value, type: :comma_separated_string_list) - - expect(config.written_env).to eq(csv_value: []) - end - end - end - describe 'idv_contact_phone_number' do it 'has config value for contact phone number' do - contact_number = IdentityConfig.store.idv_contact_phone_number + contact_number = Identity::Hostdata.config.idv_contact_phone_number expect(contact_number).to_not be_empty expect(contact_number).to match(/\(\d{3}\)\ \d{3}-\d{4}/) @@ -59,22 +43,22 @@ describe 'in_person_outage_message_enabled' do it 'has valid config values for dates when outage enabled' do - if IdentityConfig.store.in_person_outage_message_enabled - expect(IdentityConfig.store.in_person_outage_expected_update_date).to_not be_empty - expect(IdentityConfig.store.in_person_outage_emailed_by_date).to_not be_empty + if Identity::Hostdata.config.in_person_outage_message_enabled + expect(Identity::Hostdata.config.in_person_outage_expected_update_date).to_not be_empty + expect(Identity::Hostdata.config.in_person_outage_emailed_by_date).to_not be_empty - update_date = IdentityConfig.store.in_person_outage_expected_update_date.to_date + update_date = Identity::Hostdata.config.in_person_outage_expected_update_date.to_date update_month, update_day, update_year = - IdentityConfig.store.in_person_outage_expected_update_date.remove(',').split(' ') + Identity::Hostdata.config.in_person_outage_expected_update_date.remove(',').split(' ') expect(Date::MONTHNAMES.include?(update_month && update_month.capitalize)).to be_truthy expect(update_day).to_not be_empty expect(update_year).to_not be_empty expect { update_date }.to_not raise_error - email_date = IdentityConfig.store.in_person_outage_emailed_by_date.to_date + email_date = Identity::Hostdata.config.in_person_outage_emailed_by_date.to_date email_month, email_day, email_year = - IdentityConfig.store.in_person_outage_emailed_by_date.remove(',').split(' ') + Identity::Hostdata.config.in_person_outage_emailed_by_date.remove(',').split(' ') expect(Date::MONTHNAMES.include?(email_month && email_month.capitalize)).to be_truthy expect(email_day).to_not be_empty @@ -86,7 +70,7 @@ describe '.unused_keys' do it 'does not have any unused keys' do - expect(IdentityConfig.unused_keys).to be_empty + expect(Identity::Hostdata.config_builder.unused_keys).to be_empty end end end From f27ae990dcbc13f4ce73db766a98be026aabf220 Mon Sep 17 00:00:00 2001 From: Samatha Dondeti Date: Thu, 25 Apr 2024 13:57:07 -0700 Subject: [PATCH 08/14] Partneragreement helper-billingreport (#10493) * partneragreement helper-billingreport * removed all puts statments * lg-13012 iaa_reporting_helper updates to get issuers * lg-13012 sp multiple issuers * Streamline spec code * Add second issuer to existing integration --------- Co-authored-by: Zach Margolis --- app/services/iaa_reporting_helper.rb | 37 +++++++++++++ spec/services/iaa_reporting_helper_spec.rb | 60 +++++++++++++++++++--- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/app/services/iaa_reporting_helper.rb b/app/services/iaa_reporting_helper.rb index e6318c4167d..8c25a892c7d 100644 --- a/app/services/iaa_reporting_helper.rb +++ b/app/services/iaa_reporting_helper.rb @@ -17,6 +17,14 @@ def key end end + PartnerConfig = Struct.new( + :partner, + :issuers, + :start_date, + :end_date, + keyword_init: true, + ) + # @return [Array] def iaas Agreements::IaaGtc. @@ -37,4 +45,33 @@ def iaas end.compact end.sort_by(&:key) end + + def partner_accounts + Agreements::PartnerAccount. + includes(integrations: { service_provider: {}, integration_usages: :iaa_order }). + flat_map do |partner_account| + issuers = partner_account.integrations.map do |integration| + integration.service_provider.issuer + end + iaa_start_dates = partner_account.integrations.flat_map do |integration| + integration.integration_usages.flat_map do |usage| + usage.iaa_order.start_date + end + end + iaa_end_dates = partner_account.integrations.flat_map do |integration| + integration.integration_usages.flat_map do |usage| + usage.iaa_order.end_date + end + end + + if issuers.present? + PartnerConfig.new( + partner: partner_account.requesting_agency, + issuers: issuers.sort, + start_date: iaa_start_dates.min, + end_date: iaa_end_dates.max, + ) + end + end.compact + end end diff --git a/spec/services/iaa_reporting_helper_spec.rb b/spec/services/iaa_reporting_helper_spec.rb index de56b52aad0..27b4d94407d 100644 --- a/spec/services/iaa_reporting_helper_spec.rb +++ b/spec/services/iaa_reporting_helper_spec.rb @@ -3,6 +3,7 @@ RSpec.describe IaaReportingHelper do let(:partner_account1) { create(:partner_account) } let(:partner_account2) { create(:partner_account) } + let(:gtc1) do create( :iaa_gtc, @@ -31,10 +32,10 @@ end let(:integration1) do - build_integration(issuer: iaa1_sp.issuer, partner_account: partner_account1) + build_integration(service_provider: iaa1_sp, partner_account: partner_account1) end let(:integration2) do - build_integration(issuer: iaa2_sp.issuer, partner_account: partner_account2) + build_integration(service_provider: iaa2_sp, partner_account: partner_account2) end # Have to do this because of invalid check when building integration usages @@ -81,10 +82,10 @@ def build_iaa_order(order_number:, date_range:, iaa_gtc:) ) end - def build_integration(issuer:, partner_account:) + def build_integration(service_provider:, partner_account:) create( :integration, - issuer: issuer, + service_provider: service_provider, partner_account: partner_account, ) end @@ -106,7 +107,7 @@ def build_integration(issuer:, partner_account:) end let(:integration2) do - build_integration(issuer: iaa2_sp.issuer, partner_account: partner_account1) + build_integration(service_provider: iaa2_sp, partner_account: partner_account1) end let(:iaa2_key) { "#{gtc1.gtc_number}-#{format('%04d', iaa_order2.order_number)}" } @@ -124,10 +125,10 @@ def build_integration(issuer:, partner_account:) context 'IAAS on different GTCs' do let(:integration1) do - build_integration(issuer: iaa1_sp.issuer, partner_account: partner_account1) + build_integration(service_provider: iaa1_sp, partner_account: partner_account1) end let(:integration2) do - build_integration(issuer: iaa2_sp.issuer, partner_account: partner_account2) + build_integration(service_provider: iaa2_sp, partner_account: partner_account2) end let(:iaa_order1) do build_iaa_order(order_number: 1, date_range: iaa1_range, iaa_gtc: gtc1) @@ -145,4 +146,49 @@ def build_integration(issuer:, partner_account:) end end end + + describe '#partner_accounts' do + let(:service_provider1) { create(:service_provider) } + let(:service_provider2) { create(:service_provider) } + let(:service_provider3) { create(:service_provider) } + + before do + partner_account1.integrations << integration3 + partner_account2.integrations << integration4 + iaa_order1.integrations << integration3 + iaa_order2.integrations << integration4 + iaa_order2.integrations << integration5 + iaa_order1.save + iaa_order2.save + end + + context 'SPS on different Partners' do + let(:integration3) do + build_integration(service_provider: service_provider1, partner_account: partner_account1) + end + let(:integration4) do + build_integration(service_provider: service_provider2, partner_account: partner_account2) + end + let(:integration5) do + build_integration(service_provider: service_provider3, partner_account: partner_account2) + end + + it 'returns partner requesting_agency for the given partneraccountid for serviceproviders' do + expect(IaaReportingHelper.partner_accounts).to include( + IaaReportingHelper::PartnerConfig.new( + partner: partner_account1.requesting_agency, + issuers: [service_provider1.issuer], + start_date: iaa1_range.begin, + end_date: iaa1_range.end, + ), + IaaReportingHelper::PartnerConfig.new( + partner: partner_account2.requesting_agency, + issuers: [service_provider2.issuer, service_provider3.issuer].sort, + start_date: iaa2_range.begin, + end_date: iaa2_range.end, + ), + ) + end + end + end end From 45648039e7c3003ad6ebd385d1d376117c3132c7 Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Thu, 25 Apr 2024 16:22:12 -0500 Subject: [PATCH 09/14] Add Chinese translations (#10291) * add Chinese translations [skip changelog] --- config/application.yml.default | 3 +- config/application.yml.default.docker | 1 + config/locales/account/en.yml | 1 + config/locales/account/es.yml | 1 + config/locales/account/fr.yml | 1 + config/locales/account/zh.yml | 109 +++++++ config/locales/account_reset/zh.yml | 52 ++++ config/locales/anonymous_mailer/zh.yml | 12 + config/locales/banned_user/zh.yml | 5 + config/locales/components/zh.yml | 75 +++++ config/locales/countries/zh.yml | 223 ++++++++++++++ config/locales/datetime/zh.yml | 13 + config/locales/devise/zh.yml | 42 +++ config/locales/doc_auth/zh.yml | 222 ++++++++++++++ config/locales/email_addresses/zh.yml | 14 + config/locales/errors/zh.yml | 115 ++++++++ config/locales/event_disavowals/zh.yml | 8 + config/locales/event_types/zh.yml | 30 ++ config/locales/forms/zh.yml | 124 ++++++++ config/locales/headings/zh.yml | 72 +++++ config/locales/help_text/zh.yml | 22 ++ config/locales/i18n/en.yml | 1 + config/locales/i18n/es.yml | 1 + config/locales/i18n/fr.yml | 1 + config/locales/i18n/zh.yml | 9 + config/locales/idv/zh.yml | 272 ++++++++++++++++++ config/locales/image_description/zh.yml | 17 ++ config/locales/in_person_proofing/zh.yml | 138 +++++++++ config/locales/instructions/zh.yml | 78 +++++ config/locales/links/zh.yml | 27 ++ config/locales/mailer/zh.yml | 11 + config/locales/mfa/zh.yml | 13 + config/locales/notices/zh.yml | 48 ++++ config/locales/openid_connect/zh.yml | 51 ++++ config/locales/pages/zh.yml | 6 + config/locales/report_mailer/zh.yml | 7 + config/locales/risc/zh.yml | 18 ++ config/locales/saml_idp/zh.yml | 10 + config/locales/service_providers/zh.yml | 11 + config/locales/shared/zh.yml | 17 ++ config/locales/sign_up/zh.yml | 10 + config/locales/simple_form/zh.yml | 11 + config/locales/step_indicator/zh.yml | 18 ++ config/locales/telephony/zh.yml | 45 +++ config/locales/time/zh.yml | 41 +++ config/locales/titles/zh.yml | 83 ++++++ .../locales/two_factor_authentication/zh.yml | 183 ++++++++++++ .../user_authorization_confirmation/zh.yml | 7 + config/locales/user_mailer/zh.yml | 263 +++++++++++++++++ config/locales/users/zh.yml | 43 +++ config/locales/valid_email/zh.yml | 6 + config/locales/vendor_outage/zh.yml | 31 ++ config/locales/zxcvbn/zh.yml | 33 +++ lib/telephony/pinpoint/voice_sender.rb | 1 + spec/i18n_spec.rb | 194 ++++++++++++- 55 files changed, 2848 insertions(+), 2 deletions(-) create mode 100644 config/locales/account/zh.yml create mode 100644 config/locales/account_reset/zh.yml create mode 100644 config/locales/anonymous_mailer/zh.yml create mode 100644 config/locales/banned_user/zh.yml create mode 100644 config/locales/components/zh.yml create mode 100644 config/locales/countries/zh.yml create mode 100644 config/locales/datetime/zh.yml create mode 100644 config/locales/devise/zh.yml create mode 100644 config/locales/doc_auth/zh.yml create mode 100644 config/locales/email_addresses/zh.yml create mode 100644 config/locales/errors/zh.yml create mode 100644 config/locales/event_disavowals/zh.yml create mode 100644 config/locales/event_types/zh.yml create mode 100644 config/locales/forms/zh.yml create mode 100644 config/locales/headings/zh.yml create mode 100644 config/locales/help_text/zh.yml create mode 100644 config/locales/i18n/zh.yml create mode 100644 config/locales/idv/zh.yml create mode 100644 config/locales/image_description/zh.yml create mode 100644 config/locales/in_person_proofing/zh.yml create mode 100644 config/locales/instructions/zh.yml create mode 100644 config/locales/links/zh.yml create mode 100644 config/locales/mailer/zh.yml create mode 100644 config/locales/mfa/zh.yml create mode 100644 config/locales/notices/zh.yml create mode 100644 config/locales/openid_connect/zh.yml create mode 100644 config/locales/pages/zh.yml create mode 100644 config/locales/report_mailer/zh.yml create mode 100644 config/locales/risc/zh.yml create mode 100644 config/locales/saml_idp/zh.yml create mode 100644 config/locales/service_providers/zh.yml create mode 100644 config/locales/shared/zh.yml create mode 100644 config/locales/sign_up/zh.yml create mode 100644 config/locales/simple_form/zh.yml create mode 100644 config/locales/step_indicator/zh.yml create mode 100644 config/locales/telephony/zh.yml create mode 100644 config/locales/time/zh.yml create mode 100644 config/locales/titles/zh.yml create mode 100644 config/locales/two_factor_authentication/zh.yml create mode 100644 config/locales/user_authorization_confirmation/zh.yml create mode 100644 config/locales/user_mailer/zh.yml create mode 100644 config/locales/users/zh.yml create mode 100644 config/locales/valid_email/zh.yml create mode 100644 config/locales/vendor_outage/zh.yml create mode 100644 config/locales/zxcvbn/zh.yml diff --git a/config/application.yml.default b/config/application.yml.default index 89690e11765..fd11a18710c 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -23,7 +23,7 @@ aamva_verification_url: https://example.org:12345/verification/url all_redirect_uris_cache_duration_minutes: 2 allowed_ialmax_providers: '[]' allowed_verified_within_providers: '[]' -available_locales: 'en,es,fr' +available_locales: 'en,es,fr,zh' account_reset_token_valid_for_days: 1 account_reset_fraud_user_wait_period_days: account_reset_wait_period_days: 1 @@ -449,6 +449,7 @@ production: aamva_verification_url: 'https://verificationservices-cert.aamva.org:18449/dldv/2.1/online' attribute_encryption_key: attribute_encryption_key_queue: '[]' + available_locales: 'en,es,fr' aws_logo_bucket: '' dashboard_api_token: '' dashboard_url: https://dashboard.demo.login.gov diff --git a/config/application.yml.default.docker b/config/application.yml.default.docker index 8167648fad3..77df9cae3a6 100644 --- a/config/application.yml.default.docker +++ b/config/application.yml.default.docker @@ -1,6 +1,7 @@ # These configurations are used for review applications in the sandbox environment production: attribute_encryption_key: 2086dfbd15f5b0c584f3664422a1d3409a0d2aa6084f65b6ba57d64d4257431c124158670c7655e45cabe64194f7f7b6c7970153c285bdb8287ec0c4f7553e25 + available_locales: 'en,es,fr,zh' asset_host: ['env', 'ASSET_HOST'] component_previews_enabled: true database_host: ['env', 'POSTGRES_HOST'] diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index f1d9ab047e8..9fb0090121e 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -14,6 +14,7 @@ en: en: English es: Spanish fr: French + zh: Chinese sentence_connector: or updated: Your email language preference has been updated. forget_all_browsers: diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index 9d09643015e..71e5a7d216f 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -15,6 +15,7 @@ es: en: Inglés es: Español fr: Francés + zh: Chinese sentence_connector: o updated: Se actualizó su preferencia de idioma de correo electrónico. forget_all_browsers: diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index f079e06f88e..39a9d6cd145 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -15,6 +15,7 @@ fr: en: Anglais es: l’Espagnol fr: langue française + zh: Chinese sentence_connector: ou updated: Votre préférence de langue pour les e-mails a été mise à jour. forget_all_browsers: diff --git a/config/locales/account/zh.yml b/config/locales/account/zh.yml new file mode 100644 index 00000000000..2abaad09a58 --- /dev/null +++ b/config/locales/account/zh.yml @@ -0,0 +1,109 @@ +--- +zh: + account: + connected_apps: + associated: 已连接 %{timestamp} + description: 使用你的 %{app_name} 账户,你可以安全地连接到网上多个政府账户。以下列出了你目前已连接的所有账户。 + email_language: + default: '%{language} (默认)' + edit_title: 编辑电邮语言选择 + languages_list: '%{app_name} 允许你以 %{list}接受电邮沟通。' + name: + en: 英文 + es: 西班牙文 + fr: 法文 + zh: Chinese + sentence_connector: 或者 + updated: 你的电邮语言选择已更新。 + forget_all_browsers: + longer_description: + 你选择“忘掉所有浏览器“后,我们将需要额外信息来知道的确是你在登录你自己的账户。每次你要访问自己的账户时,我们都会向你要一个多因素身份证实方法(比如短信/SMS + 代码或安全密钥) + index: + auth_app_add: 添加应用程序 + auth_app_disabled: 未启用 + auth_app_enabled: 已启用 + backup_code_confirm_delete: 是的,删除代码 + backup_code_confirm_regenerate: 是的,重新生成代码 + backup_codes_exist: 生成了 + backup_codes_no_exist: 未生成 + continue_to_service_provider: 继续到 %{service_provider} + default: 默认 + device: '在 %{os}的 %{browser}' + email: 电邮地址 + email_add: 添加新电邮地址 + email_addresses: 电邮地址 + email_preferences: 电邮选择 + password: 密码 + phone: 电话号码 + phone_add: 添加电话 + piv_cac_add: 添加身份证件 + reactivation: + instructions: 你的用户资料因为重设密码最近被停用。 + link: 现在重新激活你的用户资料。 + sign_in_location_and_ip: 从 %{ip}(IP 地址可能位于 %{location})。 + unknown_location: 未知地点 + verification: + instructions: 你账户的验证需要一个验证码。 + reactivate_button: 输入你通过邮局邮件收到的代码。 + success: 我们验证了你的信息 + webauthn: 安全密钥 + webauthn_add: 添加安全密钥 + webauthn_platform: 人脸或触摸解锁 + webauthn_platform_add: 添加人脸或触摸解锁 + items: + delete_your_account: 删除你的账户 + personal_key: 个人密钥 + links: + delete_account: 删除 + regenerate_personal_key: 重设 + login: + forced_reauthentication_notice_html: %{sp_name} 需要你再次输入你的电邮和密码。 + piv_cac: 使用你的政府雇员身份证件登录 + tab_navigation: 账户创建标签页 + navigation: + add_authentication_apps: 添加身份证实应用程序 + add_email: 添加电邮地址 + add_federal_id: 添加政府雇员身份证件 + add_phone_number: 添加电话号码 + add_platform_authenticator: 添加人脸或触摸解锁 + add_security_key: 添加安全密钥 + close: 关闭 + connected_accounts: 你已连接的账户 + customer_support: 客户支持 + delete_account: 删除账户 + edit_password: 编辑密码 + forget_browsers: 忘掉所有浏览器 + get_backup_codes: 获得备用代码 + history: 历史 + landmark_label: 侧边导航 + menu: 菜单 + reset_personal_key: 重设个人密钥 + two_factor_authentication: 你的身份证实方法。 + your_account: 你的账户 + personal_key: + get_new: 获得新个人密钥 + get_new_description: 你得到新个人密钥时,老的个人密钥就失效了。 + last_generated: 最后在 %{timestamp} 生成。 + needs_new: 你的账户需要新个人密钥。如果你忘记了密码,老的个人密钥就无效了。 + old_key_will_not_work: 请打印、复印、或下载下面的新个人密钥。如果你忘记了密码,老的个人密钥就无效了。 + reset_instructions: 如果你没有个人密钥,请重设。如果你忘记了密码,就需要该个人密钥。 + reset_success: 你的个人密钥已重设。 + re_verify: + banner: 为了保护你的隐私,我们已隐藏了你的用户资料信息。 + footer: 验证身份来查看你的信息。 + revoke_consent: + link_title: 切断连接 + longer_description_html: 你的信息将不再与 %{service_provider_html} 分享。以后要访问 + %{service_provider_html},你必须授权同意分享你的信息。授权同意请到 %{service_provider_html} + 网站并登录。 + security: + link: 请到帮助中心了解更多信息 + text: 为了你的安全,你的用户资料已被锁住。 + verified_information: + address: 地址 + dob: 生日 + full_name: 姓名 + phone_number: 电话号码 + ssn: 社会保障号码 + welcome: 欢迎。 diff --git a/config/locales/account_reset/zh.yml b/config/locales/account_reset/zh.yml new file mode 100644 index 00000000000..f785dd38ac4 --- /dev/null +++ b/config/locales/account_reset/zh.yml @@ -0,0 +1,52 @@ +--- +zh: + account_reset: + cancel_request: + are_you_sure: 你确定要取消删除账户的请求吗? + cancel: 退出 + cancel_button: 取消删除账户 + title: 取消删除账户 + confirm_delete_account: + cta_html: 完成后可以 %{link_html} 或关闭这一窗口。 + info_html: %{email} 的账户已删除。我们已发送了账户删除的确认电邮。 + link_text: 设立新账户 + title: 你已删除自己的账户 + confirm_request: + close_window: 你完成后可以关闭这一窗口。 + instructions_end: 开始删除账户流程。请按照你电邮中的说明来完成这一流程。 + instructions_start: 我们已发电邮到 + security_note: 作为一项安全措施,我们也向你登记的电话号码发了短信。 + delete_account: + are_you_sure: 你确定要删除账户吗? + info: + 被锁在账户之外时,取消账户应当是最后的选择。你将无法重获与自己账户链接的任何信息。我们将通知你通过 %{app_name} + 访问的政府机构你不再有账户。账户删除后,你可以使用同一电邮地址来设立新账户。 + title: 删除账户应当是你最后的选择。 + pending: + cancel_request: 取消请求 + canceled: We have canceled your request to delete your account. + confirm: 如果现在取消,你如果要删除账户的话,必须提出新请求并再等待24个小时。 + header: 你已提出删除账户的请求。 + wait_html: 要删除账户,有 24 小时的等待期。你会在 %{interval} 收到电邮,向你说明如何完成删除。 + recovery_options: + check_saved_credential: See if you have a saved credential + check_webauthn_platform_info: 如果你在设立账户时设置了人脸或触摸解锁,务必使用你设立 %{app_name} 账户时使用的同一设备。 + header: 你确定要删除账户吗? + help_text: 如果你被锁定在账户外而且仍然需要访问 %{app_name} ,请尝试以下步骤。 + try_another_device: 使用你也许选择了“记住设备”选项的另一个设备。 + try_method_again: 再次尝试你的身份证实方法。 + use_device: 使用另一个设备。 + use_same_device: Otherwise, try using the same device where you set up face or + touch unlock. + request: + are_you_sure: 你确定无法使用自己任何一个身份证实方法吗? + delete_account: 删除你的账户 + delete_account_info: + - 删除现有账户并设立一个新账户使你能够使用同一电邮地址并设置新的身份证实方法。但是,删除账户会去掉与你账户连接的所有机构的应用程序,你将需要恢复每个连接。 + - 如果你继续的话,会首先收到一个确认电邮。 作为安全措施,你收到第一个电邮 24 小时后,会收到另外一个电邮,其中有让你继续删除账户的链接。 + info: + - 如果你无法使用先前设立的身份证实方法访问你的账户,那唯一的选择就是删除账户并设立一个新账户。 + - 账户一旦删除,我们是无法恢复的,所以请核查一下你有没有可以使用的另一种身份证实方法。 + no_cancel: 取消 + title: 账户删除和重设 + yes_continue: 是的,继续删除 diff --git a/config/locales/anonymous_mailer/zh.yml b/config/locales/anonymous_mailer/zh.yml new file mode 100644 index 00000000000..9a5b9a00e24 --- /dev/null +++ b/config/locales/anonymous_mailer/zh.yml @@ -0,0 +1,12 @@ +--- +zh: + anonymous_mailer: + password_reset_missing_user: + create_new_account: create a new account + info_no_account: You tried to reset your %{app_name} password but we don’t have + an account linked to this email address. + info_request_different: You can request your password using a different email + address that is connected to your %{app_name} account. + subject: Email not found + try_different_email: Try a different email address + use_this_email_html: Or use this email address and %{create_account_link_html}. diff --git a/config/locales/banned_user/zh.yml b/config/locales/banned_user/zh.yml new file mode 100644 index 00000000000..eaccef0d49d --- /dev/null +++ b/config/locales/banned_user/zh.yml @@ -0,0 +1,5 @@ +--- +zh: + banned_user: + details: 我们目前无法验证你的身份。 + title: 访问受限。 diff --git a/config/locales/components/zh.yml b/config/locales/components/zh.yml new file mode 100644 index 00000000000..b8cb33af83b --- /dev/null +++ b/config/locales/components/zh.yml @@ -0,0 +1,75 @@ +--- +zh: + components: + barcode: + image_alt: 条形码 + captcha_submit_button: + action_message: 验证中 + mock_score_disclaimer: 只针对内部 + mock_score_label: 'reCAPTCHA 分数: (0.0 - 1.0)' + clipboard_button: + label: 复制 + tooltip: 复制了! + countdown_alert: + time_remaining_html: '余下 %{countdown_html}' + download_button: + label: 下载 + javascript_required: + browser_instructions: '按照说明让你浏览器启用 JavaScript:' + enabled_alert: 你在浏览器中启用了 JavaScript。 + next_step: 你在浏览器中启用了 JavaScript后,请刷新本页面。 + manageable_authenticator: + cancel: 取消 + created_on: 在 %{date}创建 + delete: 删除 + delete_confirm: 你确定要删除该身份证实方法吗? + deleted: 成功地删除了一个身份证实方法 + deleting: 删除中… + done: 做完了 + manage: 管理 + manage_accessible_label: 管理身份证实方法 + nickname: 昵称 + rename: 重新命名 + renamed: 成功地重新命名了你的身份证实方法 + save: 保存 + saving: 保存中… + memorable_date: + day: 日 + errors: + invalid_date: 输入的不是一个合理日期。 + invalid_day: 日请输入 1 到 31 + invalid_month: 月请输入 1 到 21 + invalid_year: 年请输入 4 位数 + missing_day: 输入日 + missing_day_year: 输入日和年 + missing_month: 输入月 + missing_month_day: 输入月和日 + missing_month_day_year: 输入 %{label} + missing_month_year: 输入月和年 + missing_year: 输入年 + outside_date_range: 输入 %{label}(%{min} 到 %{max}) + range_overflow: 输入 %{date}或之前日期 + range_underflow: 输入 %{date}或之后日期 + month: 月 + year: 年 + one_time_code_input: + hint: + alphanumeric: '举例:123ABC' + numeric: '举例:123456' + label: 一次性代码 + password_confirmation: + confirm_label: 确认密码 + errors: + empty: 重打你的密码 + mismatch: 你的密码不一致 + toggle_label: 显示密码 + password_toggle: + label: 密码 + toggle_label: 显示密码 + phone_input: + country_code_label: 国家代码 + print_button: + label: 打印 + troubleshooting_options: + default_heading: '有疑难?可以这样做:' + ipp_heading: '更多化解疑难选项:' diff --git a/config/locales/countries/zh.yml b/config/locales/countries/zh.yml new file mode 100644 index 00000000000..b27e4290c12 --- /dev/null +++ b/config/locales/countries/zh.yml @@ -0,0 +1,223 @@ +--- +zh: + countries: + ad: 安道尔 + ae: 阿拉伯联合酋长国(阿联酋) + af: 阿富汗 + ag: 安提瓜和巴布达 + ai: 安圭拉 + al: 阿尔巴尼亚 + am: 亚美尼亚 + ao: 安哥拉 + ar: 阿根廷 + as: 美属萨摩亚 + at: 奥地利 + au: 澳大利亚 + aw: 阿鲁巴 + az: 阿塞拜疆 + ba: 波斯尼亚和黑塞哥维纳 + bb: 巴巴多斯 + bd: 孟加拉 + be: 比利时 + bf: 布基纳法索 + bg: 保加利亚 + bh: 巴林 + bi: 布隆迪 + bj: 贝宁 + bm: 百慕大 + bn: 文莱 + bo: 玻利维亚 + bq: 荷属安的列斯 + br: 巴西 + bs: 巴哈马 + bt: 不丹 + bw: 博茨瓦纳 + by: 白俄罗斯 + bz: 伯利兹 + ca: 加拿大 + cd: 刚果民主共和国 + cf: 中非共和国 + cg: 刚果共和国 + ch: 瑞士 + ci: 科特迪瓦 + ck: 库克群岛 + cl: 智利 + cm: 喀麦隆 + cn: 中国 + co: 哥伦比亚 + cr: 哥斯达黎加 + cv: 佛得角 + cy: 塞浦路斯 + cz: 捷克(捷克共和国) + de: 德国 + dj: 吉布提 + dk: 丹麦 + dm: 多米尼加 + do: 多米尼加共和国 + dz: 阿尔及利亚 + ec: 厄瓜多尔 + ee: 爱沙尼亚 + eg: 埃及 + er: 厄立特里亚 + es: 西班牙 + et: 埃塞俄比亚 + fi: 芬兰 + fj: 斐济 + fm: 密克罗尼西亚(联邦) + fo: 法罗群岛 + fr: 法国 + ga: 加蓬 + gb: 英国 + gd: 格林纳达 + ge: 格鲁吉亚 + gf: 法属圭亚那 + gg: 根西 + gh: 加纳 + gi: 直布罗陀 + gl: 格陵兰 + gm: 冈比亚 + gn: 几内亚 + gp: 瓜德罗普 + gq: 赤道几内亚 + gr: 希腊 + gt: 危地马拉 + gu: 关岛 + gw: 几内亚比绍 + gy: 圭亚那 + hk: 香港 + hn: 洪都拉斯 + hr: 克罗地亚 + ht: 海地 + hu: 匈牙利 + id: 印度尼西亚 + ie: 爱尔兰 + il: 以色列 + im: 马恩岛 + in: 印度 + iq: 伊拉克 + is: 冰岛 + it: 意大利 + je: 泽西 + jm: 牙买加 + jo: 约旦 + jp: 日本 + ke: 肯尼亚 + kg: 吉尔吉斯斯坦 + kh: 柬埔寨 + ki: 基里巴斯 + km: 科摩罗 + kn: 圣基茨和尼维斯 + kr: 韩国 + kw: 科威特 + ky: 开曼群岛 + kz: 哈萨克斯坦 + la: 老挝 + lb: 黎巴嫩 + lc: 圣卢西亚 + li: 列支敦士登 + lk: 斯里兰卡 + lr: 利比里亚 + ls: 莱索托 + lt: 立陶宛 + lu: 卢森堡 + lv: 拉脱维亚 + ly: 利比亚 + ma: 摩洛哥 + mc: 摩纳哥 + md: 摩尔多瓦 + me: 黑山 + mg: 马达加斯加 + mh: 马绍尔群岛 + mk: 马其顿 + ml: 马里 + mm: 缅甸 + mn: 蒙古 + mo: 澳门 + mp: 北马里亚纳群岛 + mq: 马提尼克 + mr: 毛里塔尼亚 + ms: 蒙特塞拉特 + mt: 马耳他 + mu: 毛里求斯 + mv: 马尔代夫 + mw: 马拉维 + mx: 墨西哥 + my: 马来西亚 + mz: 莫桑比克 + na: 纳米比亚 + nc: 新喀里多尼亚 + ne: 尼日尔 + ng: 尼日利亚 + ni: 尼加拉瓜 + nl: 荷兰 + 'no': 挪威 + np: 尼泊尔 + nu: 纽埃 + nz: 新西兰 + om: 阿曼 + pa: 巴拿马 + pe: 秘鲁 + pf: 法属波利尼西亚 + pg: 巴布亚新几内亚 + ph: 菲律宾 + pk: 巴基斯坦 + pl: 波兰 + pr: 波多黎各 + ps: 巴勒斯坦 + pt: 葡萄牙 + pw: 帕劳 + py: 巴拉圭 + qa: 卡塔尔 + re: 留尼汪(法国) + ro: 罗马尼亚 + rs: 塞尔维亚 + ru: 俄罗斯 + rw: 卢旺达 + sa: 沙特阿拉伯 + sb: 所罗门群岛 + sc: 塞舌尔 + se: 瑞典 + sg: 新加坡 + si: 斯洛文尼亚 + sk: 斯洛伐克 + sl: 塞拉利昂 + sm: 圣马力诺 + sn: 塞内加尔 + so: 索马里 + sr: 苏里南 + ss: 南苏丹 + st: 圣多美和普林西比 + sv: 萨尔瓦多 + sz: 史瓦帝尼(前斯威士兰) + tc: 特克斯和凯科斯群岛 + td: 乍得 + tg: 多哥 + th: 泰国 + tj: 塔吉克斯坦 + tl: 东帝汶 + tm: 土库曼斯坦 + tn: 突尼斯 + to: 汤加 + tr: 土耳其 + tt: 特立尼达和多巴哥 + tv: 图瓦卢 + tw: 台湾 + tz: 坦桑尼亚 + ua: 乌克兰 + ug: 乌干达 + us: 美国 + uy: 乌拉圭 + uz: 乌兹别克斯坦 + vc: 圣文森特和格林钠丁斯 + ve: 委内瑞拉 + vg: 英属维尔京群岛 + vi: 美属维尔京群岛 + vn: 越南 + vu: 瓦努阿图 + ws: 萨摩亚 + xk: 科索沃 + ye: 也门 + yt: 马约特 + za: 南非 + zm: 赞比亚 + zw: 津巴布韦 diff --git a/config/locales/datetime/zh.yml b/config/locales/datetime/zh.yml new file mode 100644 index 00000000000..0451f8512e4 --- /dev/null +++ b/config/locales/datetime/zh.yml @@ -0,0 +1,13 @@ +--- +zh: + datetime: + dotiw: + last_word_connector: ' 和 ' + minutes: + one: 1 分钟 + other: '%{count} 分钟' + seconds: + one: 1 秒种 + other: '%{count} 秒' + two_words_connector: ' 和 ' + words_connector: ', ' diff --git a/config/locales/devise/zh.yml b/config/locales/devise/zh.yml new file mode 100644 index 00000000000..7c8632bf3f8 --- /dev/null +++ b/config/locales/devise/zh.yml @@ -0,0 +1,42 @@ +--- +zh: + devise: + confirmations: + already_confirmed: 你的电邮地址已确认。%{action} + confirmed: 你已确认了你的电邮地址。 + confirmed_but_must_set_password: 你已确认了你的电邮地址。 + confirmed_but_remove_from_other_account: 该电邮地址已与一个 + %{app_name}账户相关联,所以我们不能把它加到另外一个账户上。你必须首先将其从与之相关的账户中删除或去掉。要做到这一点,请用该电邮地址登录。 + confirmed_but_sign_in: 你已确认了你的电邮地址。请登录来查看你的用户资料。 + sign_in: 请登录。 + failure: + already_authenticated: '' + inactive: 你的账户尚未激活。 + invalid_html: 你输入的电邮或密码是错的。尝试 %{link_html}。 + invalid_link_text: 重设你的密码 + last_attempt: 你还能再试一次,然后你的账户就会被锁住。 + locked: 你的账户已被锁住。 + not_found_in_database_html: 你输入的电邮或密码是错的。尝试 %{link_html}。 + not_found_in_database_link_text: 重设你的密码 + session_limited: 你的登录凭据在另一个浏览器被使用。请再次登录在该浏览器中继续。 + timeout: 你的时间已过。请再次登入来继续。 + unauthenticated: 你的时间已过。请再次登入来继续。 + unconfirmed: 你需要确认电邮地址才能继续。 + mailer: + password_updated: + subject: 你的密码已更改。 + passwords: + choose_new_password: 选择一个新密码。 + invalid_token: 重设密码的令牌有误。请再试一下。 + no_token: 要重设你的密码,请使用你收到的重设密码电邮里的链接。如果在把链接黏贴到浏览器中,请务必黏贴整个链接。 + send_instructions: 几分钟后你会收到电邮,告诉你如何重设密码。 + send_paranoid_instructions: 几分钟后你会收到电邮,告诉你如何重设密码。 + token_expired: 你重设密码用时过长。请再试一下。 + updated: 你的密码已更改。你现在已登入。 + updated_not_active: 你的密码已更改。请用你的新密码登录。 + registrations: + close_window: 完成后可以关闭这一窗口。 + destroyed: 你的账户已成功删除。 + sessions: + signed_in: '' + signed_out: 你现在已登出。 diff --git a/config/locales/doc_auth/zh.yml b/config/locales/doc_auth/zh.yml new file mode 100644 index 00000000000..cd1a8014e9e --- /dev/null +++ b/config/locales/doc_auth/zh.yml @@ -0,0 +1,222 @@ +--- +zh: + doc_auth: + accessible_labels: + camera_video_capture_instructions: 我们会自动拍张照片。 + camera_video_capture_label: 带框的取景器能把你身份证件放在中间。 + document_capture_dialog: 文档扫描 + buttons: + add_new_photos: 添加新照片 + close: Close + continue: 继续 + take_or_upload_picture_html: '拍照 或 + 上传照片' + take_picture: 拍照 + take_picture_retry: 重新拍照 + upload_picture: 上传照片 + errors: + alerts: + address_check: 我们读取不到你身份证件上的地址。尝试重拍。 + barcode_content_check: + 我们读取不到你身份证件背面的条形码可能是条形码有问题,也可能是条形码类型很新,我们还不认识。如果你有州政府颁发的别的身份证件, + 请使用那个身份证件。 + barcode_read_check: 我们读取不到你身份证件背面的条形码尝试重拍一张照片。 + birth_date_checks: 我们读取不到你身份证件上的生日。尝试重拍。 + control_number_check: 我们读取不到你身份证件背面的参考数字。尝试重拍一张照片。 + doc_crosscheck: 我们无法认出你的身份证件。可能是因为你的身份证件破旧受损,或者正反两面对不上。尝试重拍。 + doc_number_checks: 我们读取不到你身份证件上的文档号。尝试重拍。 + expiration_checks: 文件已过期,或者我们读取不到你身份证件上的过期日期。如果你的身份证件还没过期,尝试重拍。 + full_name_check: 我们读取不到你身份证件上的姓名。尝试重拍。 + id_not_recognized: 我们无法辨认你的身份证件。可能是因为你的身份证件破旧受损,或是我们不认识的一类身份证件。尝试重拍。 + id_not_verified: 我们无法验证你的身份证件。可能因为你拍照时身份证件移动了。尝试重拍。 + issue_date_checks: 我们读取不到你身份证件上的颁发日期。尝试重拍。 + ref_control_number_check: 我们读取不到控制号码条码。尝试重拍。 + selfie_not_live: Try taking a photo of yourself again. Make sure your whole face + is clear and visible in the photo. + selfie_not_live_help_link_text: Review more tips for taking a clear photo of yourself + selfie_poor_quality: Try taking a photo of yourself again. Make sure your whole + face is clear and visible in the photo. + sex_check: 我们读取不到你身份证件上的性别。尝试重拍。 + visible_color_check: 我们无法验证你的身份证件。可能你拍照时身份证件移动了,或者照片太暗。尝试在亮一点的灯光下重拍。 + visible_photo_check: 我们无法验证你身份证件上的照片。尝试重拍。 + barcode_attention: + confirm_info: 如果以下信息不正确,请上传你州政府颁发的身份证件的新照片。 + heading: 我们读取不到你身份证件上的条形码。 + camera: + blocked: 你的镜头被遮住了。 + blocked_detail: 我们没有使用相机的许可。请检查一下你的浏览器或系统设置,重新加载该页面,或者上传照片。 + failed: 相机未开启,请再试一次。 + card_type: 再用你的驾照或州政府颁发的身份证件试一次。 + doc: + doc_type_check: 你的驾照或身份证件必须是美国一个州或属地颁发的。我们不接受任何其他形式的身份证件,比如护照和军队身份证件。 + resubmit_failed_image: 你已经试过这张图像,该图像不行。请尝试添加一个不同的图像。 + wrong_id_type_html: + 我们只接受由美国的一个州或属地颁发的驾照或身份证件。我们不接受任何其他形式的身份证件,比如护照和军队身份证件。 + 了解更多有关我们接受的身份证件的信息 + dpi: + failed_short: 图像太小或模糊,请再试一次。 + top_msg: 我们无法辨认你的身份证件。你的图像尺寸可能太小,或者照片中你的身份证件太小或太模糊。确保你的身份证件在图框中比较大,然后试着重拍一下。 + top_msg_plural: 我们无法读取你的身份证件。你的图像尺寸可能太小,或者照片中你的身份证件太小或模糊。确保你的身份证件在图片框中很大,然后试着拍一张新照片。 + file_type: + invalid: 这一文件类型我们不接受。请选择一个 JPG 或 PNG 文件。 + general: + fallback_field_level: 请添加一个新图像 + multiple_back_id_failures: 我们无法验证你身份证件的背面。尝试重拍一张。 + multiple_front_id_failures: 我们无法验证你身份证件的正面。尝试重拍一张。 + network_error: 我们这边有技术困难。请稍后再提交你的图像。 + no_liveness: 尝试重拍。 + selfie_failure: Try taking your photos again. Make sure all of your photos are + clear and in focus. + selfie_failure_help_link_text: Review more tips for taking clear photos + glare: + failed_short: 图像有炫光,请再试一次。 + top_msg: 我们无法读取你的身份证件。你的照片可能有炫光。确保你相机上的闪光是关掉的,然后再尝试重拍张。 + top_msg_plural: 我们无法读取你的身份证件。你的照片可能有炫光。确保你相机上的闪光是关掉的,然后再尝试重拍。 + http: + image_load: + failed_short: 系统不支持图像文件,请再试一次。 + top_msg: 你添加的图像文件系统不支持。请重拍你的身份证件并再试一次。 + image_size: + failed_short: 系统不支持图像文件,请再试一次。 + top_msg: 你的图像尺寸太大或太小。请添加你身份证件的图像,其像素应约为 2025 x 1275。 + pixel_depth: + failed_short: 系统不支持图像文件,请再试一次。 + top_msg: 你图像文件的像素深度系统不支持。请重拍你的身份证件并再试一次。系统支持的图像像素深度为 24位 RGB。 + not_a_file: 你选择的不是一个正确的文件。 + pii: + birth_date_min_age: 你的生日不满足最低年龄要求。 + sharpness: + failed_short: 图像模糊,请再试一次。 + top_msg: 我们无法读取你的身份证件。你的照片可能太模糊或太暗。尝试在明亮的地方重拍一张。 + top_msg_plural: 我们无法读取你的身份证件。你的照片可能太模糊或太暗。尝试在明亮的地方重拍一张。 + upload_error: 抱歉,我们这边出错了。 + forms: + captured_image: 扫描到的图像 + change_file: 更改文件 + choose_file_html: 将文件拖到此处或者 从文件夹中选择。 + doc_success: 我们验证了你的信息 + selected_file: 被选文件 + headings: + address: 更新你的邮政地址 + back: 驾照或州政府颁发身份证件的背面。 + capture_complete: 我们验证了你的身份证件 + capture_scan_warning_html: 我们读取不到你身份证件上的条形码。如果以下信息不正确,请将州政府颁发的身份证件%{link_html}。 + capture_scan_warning_link: 上传新照片 + document_capture: 添加你身份证件的照片 + document_capture_back: 你身份证件的背面 + document_capture_front: 你身份证件的正面 + document_capture_selfie: 你本人照片 + document_capture_subheader_id: 驾照或州政府颁发的身份证件 + document_capture_subheader_selfie: 你本人照片 + document_capture_with_selfie: 添加你身份证件和本人的照片 + front: 驾照或州政府颁发身份证件的正面 + how_to_verify: 选择你想如何验证身份 + hybrid_handoff: 你想怎么添加身份证件? + hybrid_handoff_selfie: Enter your phone number to switch devices + interstitial: 我们正在处理你的图像 + lets_go: 你的身份是如何验证的 + no_ssn: 没有社会保障号码? + review_issues: 检查一下你的图片并再试一次 + secure_account: 保护你的账户安全 + selfie: 照片 + ssn: 输入你的社会保障号码 + ssn_update: 更新你的社会保障号码 + text_message: 我们给你的手机发了短信 + upload_from_computer: 在这台电脑上继续 + upload_from_phone: 使用手机拍照 + verify_at_post_office: 去邮局验证身份 + verify_identity: 验证你的身份 + verify_online: 在网上验证你的身份 + welcome: 开始验证你的身份 + hybrid_flow_warning: + explanation_html: 你在使用 %{app_name} 验证身份以访问 + %{service_provider_name} 及其服务。 + explanation_non_sp_html: 你在使用 %{app_name} 验证身份。 + only_add_if_text: '只有在以下情况下才添加你的身份证件:' + only_add_own_account: 你在使用自己的 %{app_name} 账户 + only_add_phone_verify: 你要求 %{app_name} 使用你的电话来验证你的身份证件。 + only_add_sp_services_html: 你在试图访问 %{service_provider_name} 服务。 + info: + address_guidance_puerto_rico_html: 波多黎各居民:

编辑你的地址,在地址第 2 行列出你的 urbanization 或 condominium。 + capture_status_big_document: 太近了 + capture_status_capturing: 扫描中 + capture_status_none: 对齐 + capture_status_small_document: 靠近一些 + capture_status_tap_to_capture: 点击来扫描 + exit: + with_sp: 退出 %{app_name} 并返回 %{app_name} + without_sp: 退出身份验证并到你的账户页面 + getting_started_html: '%{sp_name} needs to make sure you are you — not someone + pretending to be you. %{link_html}' + getting_started_learn_more: Learn more about verifying your identity + how_to_verify: 你有在网上验证身份或者到参与邮件亲身验证身份的选择 + how_to_verify_troubleshooting_options_header: 想对验证身份获得更多了解吗? + hybrid_handoff: 我们将通过读取州政府颁发给你的身份证件来收集你的信息。 + hybrid_handoff_ipp_html: Don’t have a mobile phone? You can + verify your identity at a United States Post Office instead. + image_loaded: 图像已加载 + image_loading: 图像加载中 + image_updated: 图像已上传 + interstitial_eta: 可能需要一分钟时间这一步完成后,我们会自动加载下一步。 + interstitial_thanks: 感谢你的耐心! + keep_window_open: 请勿关闭此窗口。 + learn_more: 对于我们如何保护你的敏感信息获得更多了解 + lets_go: '身份验证包括两部分:' + link_sent: 请查看你的手机并按照说明拍一张你州政府颁发的身份证件的照片。 + link_sent_complete_no_polling: 照好以后,在这里点击“继续”,完成验证身份。 + link_sent_complete_polling: 下一步会自动加载。 + no_ssn: 你必须有社会保障号码才能完成身份验证。 + review_examples_of_photos: 查看如何拍出身份证件清晰照片的示例。 + secure_account: 我们会用你的密码对你的账户加密。加密意味着你的数据得到了保护,而且只有你能够访问或变更你的信息。 + selfie_capture_content: We’ll check that you are the person on your ID. + selfie_capture_status: + face_close_to_border: Too close to the frame + face_not_found: Face not found + face_too_small: Face too small + too_many_faces: Too many faces + ssn: 我们需要你的社会保障号码来证实你的姓名、生日和地址。 + stepping_up_html: Verify your identity again to access this service. %{link_html} + tag: 建议 + upload_from_computer: 没有手机?从该电脑上传你身份证件的照片。 + upload_from_phone: 你不必重新登录,而且拍完照后可以转回到该电脑。你的手机必须带有相机和网络浏览器。 + verify_at_post_office_description: 如果你没有移动设备或者无法轻松拍身份证件照片,这一选项更好。 + verify_at_post_office_instruction: 你在网上输入身份证件信息,然后到一个参与邮件去亲身验证身份。 + verify_at_post_office_link_text: 对亲身验证获得更多了解 + verify_identity: 我们会要求获得你的个人信息并通过与公共记录核对来验证你的身份。 + verify_online_description: 如果你没有移动设备或者无法轻松拍身份证件照片,这一选项更好。 + verify_online_instruction: 你将拍身份证件的照片来完全在网上验证身份。大多数用户都能轻松完成这样流程。 + verify_online_link_text: 对网上验证获得更多了解 + you_entered: '你输入了:' + instructions: + bullet1: 州政府颁发的身份证件 + bullet2: 社会保障号码 + bullet3: 电话号码或家庭地址 + bullet4: Re-enter your %{app_name} password + consent: 在此框打勾,意味着你允许 %{app_name} 索要、使用、保留并分享你的个人信息。我们会使用这些信息来验证你的身份。 + getting_started: 'You’ll need to:' + learn_more: 了解有关我们隐私和安全措施的更多信息。 + switch_back: 然后再返回你的电脑完成验证身份。 + switch_back_image: 从手机指向电脑的箭头 + test_ssn: 在测试环境中只有以 900- 或者 666- 打头的社保号才被视为是对的。请勿在该字段中输入你真实的显示个人身份的信息。 + text1: 你的身份证件不能是过期的。 + text2: 你不需要有卡。 + text3: We match your phone number with your personal information and send a + one-time code to your phone. + text4: Your password saves and encrypts your personal information. + tips: + document_capture_hint: 必须是 JPG 或 PNG + document_capture_id_text1: 使用暗色背景 + document_capture_id_text2: 在平坦平面上拍照。 + document_capture_id_text3: 不要使用相机的闪光灯 + document_capture_id_text4: 文件大小应当至少 2 兆。 + document_capture_selfie_id_header_text: 拍出清晰照片的提示 + document_capture_selfie_selfie_text: 拍出清晰照片的提示 + document_capture_selfie_text1: 把设备放在眼睛水平 + document_capture_selfie_text2: 确保你整个面孔都可以看到 + document_capture_selfie_text3: 在光线充沛的地方拍照 + document_capture_selfie_text4: Make sure your whole face is visible within the green circle. + review_issues_id_header_text: '请仔细查看你州政府颁发身份证件的图像:' + review_issues_id_text1: 你是否使用了暗色背景? + review_issues_id_text2: 是否在平坦平面上拍的照? + review_issues_id_text3: 你相机的闪光灯是否关闭? + review_issues_id_text4: 是否所有细节都清晰可见? diff --git a/config/locales/email_addresses/zh.yml b/config/locales/email_addresses/zh.yml new file mode 100644 index 00000000000..adee09de33b --- /dev/null +++ b/config/locales/email_addresses/zh.yml @@ -0,0 +1,14 @@ +--- +zh: + email_addresses: + add: + duplicate: 该电邮地址已注册到你的账户。 + limit: 你添加的电邮地址数目已达最多。 + delete: + bullet1: 使用该电邮地址你无法登录进入 %{app_name} (或任何其他与你账户关联的政府应用程序)。 + bullet2: 你不会在该电邮地址得到账户通知。 + confirm: 你确定要删除 %{email}吗? + failure: 无法删除这一电邮地址。 + success: 这一电邮地址已被去掉。 + warning: 如果你删除你的电邮地址 + unconfirmed: '(未确认)' diff --git a/config/locales/errors/zh.yml b/config/locales/errors/zh.yml new file mode 100644 index 00000000000..54df99bc016 --- /dev/null +++ b/config/locales/errors/zh.yml @@ -0,0 +1,115 @@ +--- +zh: + errors: + account_reset: + cancel_token_invalid: 取消令牌无效 + cancel_token_missing: 取消令牌缺失 + granted_token_expired: 删除你 %{app_name} 账户的链接已过期。请创建另一个删除账户请求。 + granted_token_invalid: 删除你 %{app_name} 账户的链接有误。请再次尝试点击你电邮中的链接。 + granted_token_missing: 删除你 %{app_name} 账户的链接有误。请再次尝试点击你电邮中的链接。 + attributes: + password: + avoid_using_phrases_that_are_easily_guessed: 避免使用容易被人猜出的短语,比如你电邮的一部分或与自己有关的日期。 + too_short: + one: 密码必须至少有一个字符 + other: 密码必须至少有%{count}个字符 + capture_doc: + invalid_link: 链接已过期或有误。 请要求另外一个在手机上验证身份的链接。 + confirm_password_incorrect: 密码不对。 + doc_auth: + consent_form: 在你能继续之前,你必须授予我们你的同意。请在下面的框打勾然后点击继续。 + doc_type_not_supported_heading: 我们只接受驾照或州政府颁发的 ID。 + document_capture_canceled: You have canceled uploading photos of your ID on your phone. + how_to_verify_form: 选择一个验证身份的方法。 + phone_step_incomplete: 在继续之前你必须使用手机上传你身份证件的图片。我们已给你发了带有说明的链接。 + rate_limited_heading: 我们无法验证你的身份证件。 + rate_limited_subheading: 尝试再拍照片 + rate_limited_text_html: '为了你的安全,我们限制你在网上尝试验证文件的次数。 %{timeout} 后再试。' + selfie_fail_heading: We couldn’t match the photo of yourself to your ID + selfie_not_live_or_poor_quality_heading: We could not verify the photo of yourself + send_link_limited: 你尝试了太多次。请在 %{timeout}后再试。你也可以返回并选择使用电脑。 + enter_code: + rate_limited_html: 你输入错误验证码太多次。 %{timeout} 后再试。 + general: 哎呀,出错了。请再试一次。 + invalid_totp: 代码有误。请再试一次。 + manage_authenticator: + internal_error: 处理你的请求时内部出错。请再试一次。 + remove_only_method_error: 你不能去掉自己唯一的身份证实方法。 + unique_name_error: 名字已在使用。请使用一个不同的名字。 + max_password_attempts_reached: 你输入了太多不正确的密码。你可以使用“忘了密码?”链接来重设密码。 + messages: + already_confirmed: 已确认,请尝试登录 + blank: 请填写这一字段。 + blank_cert_element_req: We cannot detect a certificate in your request. + confirmation_code_incorrect: 验证码不对。你打字打对了吗? + confirmation_invalid_token: 确认链接有误。链接已过期,或者你已确认了你的账户。 + confirmation_period_expired: 过期的确认链接。你可以点击“重新发送确认说明”来得到另一个。 + expired: 已过期,请要求一个新的。 + format_mismatch: 请使用与要求一致的格式。 + gpo_otp_expired: 你的验证码已过期。请要求另外一封信来得到新代码。 + improbable_phone: 电话号码有误。请保证你输入的电话号码无误。 + inclusion: 没有在清单中 + invalid_calling_area: 不支持给那个号码打的电话。如果你有一个可接受短信(SMS)的电话,请尝试用短信。 + invalid_phone_number: + international: 输入一个位数正确的电话号码。 + us: 输入 10 位的电话号码。 + invalid_recaptcha_token: 你必须完成预防滥发邮件测验。 + invalid_sms_number: 输入的电话号码不支持短信。尝试接听电话选项。 + invalid_voice_number: 电话号码有误。检查一下你是否输入了正确的国家代码或区域代码。 + missing_field: 请填写这一字段。 + no_pending_profile: 没有等待验证的用户资料 + not_a_number: 不是数字 + otp_format: 输入你完整的一次性代码(没有空白或特殊字符) + password_incorrect: 密码不对。 + password_mismatch: 你的密码不一致 + personal_key_incorrect: 个人密钥不对 + phone_carrier: 抱歉,我们目前无法支持这一电话运营商。请选择一个不同的号码再试一次。 + phone_confirmation_limited: 你尝试了太多次。请在 %{timeout}后再试。 + phone_duplicate: 该账户已在使用你输入的电话号码作为身份证实器。请使用一个不同的电话号码。 + phone_required: 电话号码是必需的。 + phone_unsupported: 抱歉,我们目前无法发送短信(SMS)。请尝试下面的接听电话选项或者使用你的个人密钥。 + premium_rate_phone: 这好像是一个高价电话号码。请选择一个不同的号码再试一次。 + pwned_password: 你输入的密码不安全。名列因数据泄露而被暴露的已知密码清单上。 + stronger_password: 输入一个强密码 + too_long: + one: 太长(最多 1 个字符) + other: 太长(最多 %{count}个字符) + too_short: + one: 太短(最少一个字符)。 + other: 太短(最少 %{count}个字符)。 + try_again: 请再试一次。 + unauthorized_authn_context: 未经授权的身份证实背景 + unauthorized_nameid_format: 未经授权的 nameID 格式 + unauthorized_service_provider: 未经授权的服务提供商 + voip_check_error: 检查你的电话时出错,请再试一次。 + voip_phone: 该号码是一个基于网络的(VOIP)电话服务。请选择一个不同的号码再试一次。 + weak_password: 你的密码不够强。%{feedback} + wrong_length: + one: 长度不对(应当是 1 个字符) + other: 长度不对(应当是 %{count} 个字符) + piv_cac_setup: + unique_name: 这个名字已被使用。请选择一个不同的名字。 + registration: + terms: 在你能继续之前,你必须授予我们你的同意。请在下面的框打勾然后点击继续。 + sign_in: + bad_password_limit: 你已超出登录尝试允许最多次数。 + two_factor_auth_setup: + must_select_additional_option: 请选择一个额外的身份证实方法。 + must_select_option: 选择一个身份证实方法。 + verify_personal_key: + rate_limited: 你尝试了太多次。请在 %{timeout}后再试。 + webauthn_platform_setup: + account_setup_error: 我们无法添加人脸或触摸解锁。请再试一次或者 %{link}。 + already_registered: 人脸或触摸解锁已在该设备上注册。请尝试添加另外一种身份证实方法。 + choose_another_method: 选择另一个身份证实方法 + general_error: 我们无法添加人脸或触摸解锁。再试一次或选择另一个身份证实方法。 + not_supported: + 你的浏览器不支持人脸或触摸解锁。请使用最新版本的 Google Chrome、Microsoft Edge 或 Safari + 来使用人脸或触摸解锁。 + unique_name: 这个名字已被使用。请选择一个不同的名字。 + webauthn_setup: + additional_methods_link: 选择另一个身份证实方法 + already_registered: 安全密钥已注册。请尝试一个不同的安全密钥。 + general_error_html: 我们无法添加安全密钥,请再试一次或者 %{link_html}。 + not_supported: 你的浏览器不支持安全密钥。请使用最新版本的 Google Chrome、Microsoft Edge 或 Safari 来使用安全密钥。 + unique_name: 这个名字已被使用。请选择一个不同的名字。 diff --git a/config/locales/event_disavowals/zh.yml b/config/locales/event_disavowals/zh.yml new file mode 100644 index 00000000000..5b92f031953 --- /dev/null +++ b/config/locales/event_disavowals/zh.yml @@ -0,0 +1,8 @@ +--- +zh: + event_disavowals: + errors: + event_already_disavowed: 你已使用了那个链接来更改密码。登录以更改你的密码。 + event_disavowal_expired: 更改你密码的链接已过期。登录以更改你的密码。 + event_not_found: 更改你密码的链接有误。登入来更改你的密码。 + no_account: 没有与此事件相关的账户。 diff --git a/config/locales/event_types/zh.yml b/config/locales/event_types/zh.yml new file mode 100644 index 00000000000..d03d4faa986 --- /dev/null +++ b/config/locales/event_types/zh.yml @@ -0,0 +1,30 @@ +--- +zh: + event_types: + account_created: 账户已设立 + account_verified: 账户已验证 + authenticated_at: 已在 %{service_provider}登录 + authenticated_at_html: 已在 %{service_provider_link_html}登录 + authenticator_disabled: 身份证实器应用程序已去掉 + authenticator_enabled: 身份证实器应用程序已添加 + backup_codes_added: 备用代码已添加 + eastern_timestamp: '%{timestamp} (东部)' + email_changed: 电邮地址已更改 + email_deleted: 电邮地址已删除 + gpo_mail_sent: 信已发送 + new_personal_key: 个人密钥已更改 + password_changed: 密码已更改 + password_invalidated: 密码由 %{app_name}重设 + personal_key_used: 个人密钥被使用来登录 + phone_added: 电话号码已添加。 + phone_changed: 电话号码已更改。 + phone_confirmed: 电话已确认 + phone_removed: 电话号码已去掉。 + piv_cac_disabled: PIV/CAC 卡无关联 + piv_cac_enabled: PIV/CAC 卡已关联 + sign_in_after_2fa: 已使用第二个因素登录 + sign_in_before_2fa: 已使用密码登录 + sign_in_notification_timeframe_expired: Expired notification timeframe for sign-in from new device + sign_in_unsuccessful_2fa: Failed to authenticate + webauthn_key_added: 硬件安全密钥已添加 + webauthn_key_removed: 硬件安全密钥已去掉 diff --git a/config/locales/forms/zh.yml b/config/locales/forms/zh.yml new file mode 100644 index 00000000000..3096dee48de --- /dev/null +++ b/config/locales/forms/zh.yml @@ -0,0 +1,124 @@ +--- +zh: + forms: + backup_code: + caution_codes: 每个代码只能使用一次。你用完所有 10 个代码后我们会给你新代码。 + caution_delete: 如果你删除自己的备用代码,就无法用其来登录了。 + confirm_delete: 你确定要删除备用代码吗? + generate: 获得代码 + last_code: 你用了最后一个备用代码。请打印、复制、或下载下面的代码。你下次登录时可以使用这些新代码。 + regenerate: 获得新代码 + saved: 我已把自己的备用代码放在一个安全的地方。 + subinfo_html: '如果你丢了设备,就会需要这些代码来登录 %{app_name}。保存或打印代码并将其放在安全的地方。' + title: 保存这些备用代码 + backup_code_regenerate: + caution: 如果你重新生成备用代码,会收到新的一套备用代码。你原来的备用代码就会失效。 + confirm: 你确定要重新生成备用代码吗? + backup_code_reminder: + body_info: 如果你无法使用自己的主要身份证实方法,可以使用备用代码重新获得对账户的访问权。 + have_codes: 我有代码 + heading: 你的备用代码还有吗? + need_new_codes: 我需要新的一套备用代码。 + buttons: + back: 返回 + cancel: 是的,取消 + confirm: 确认 + continue: 继续 + continue_ipp: Continue in person + continue_remote: Continue online + delete: 删除 + disable: 删除 + edit: 编辑 + manage: 管理 + send_link: 发送链接 + send_one_time_code: 发送代码 + submit: + confirm_change: 确认变更 + default: 提交 + update: 更新 + upload_photos: 上传照片 + confirmation: + show_hdr: 设一个强密码 + email: + buttons: + delete: 删除电邮地址 + example: '举例:' + messages: + remember_device: 记住这个浏览器 + password: 密码 + passwords: + edit: + buttons: + submit: 变更密码 + labels: + password: 新密码 + personal_key: + alternative: 没有你的个人密钥? + confirmation_label: 个人密钥 + download: 下载(文本文件)。 + instructions: 请在下面输入你的个人密钥,以确认你有个人密钥。 + required_checkbox: 我将个人密钥存放到了一个安全的地方。 + title: 输入你的个人密钥 + personal_key_partial: + acknowledgement: + header: 如果你忘记了密码,就需要你的个人密钥。请将其保管好,不要与任何人分享。 + help_link_text: 了解更多有关个人密钥的信息。 + text: 如果你在没有个人密钥情况下重设你的密码,将需要再次验证你的身份。 + header: 保存你的个人密钥 + phone: + buttons: + delete: 去掉电话 + piv_cac_login: + submit: 插入 PIV/CAC 卡 + piv_cac_mfa: + submit: 提供 PIV/CAC 卡 + piv_cac_setup: + nickname: PIV/CAC 卡昵称 + no_thanks: 不用,谢谢。 + piv_cac_intro_html: + 每次你登录时,作为双因素身份证实的一部分,我们会要你提供你的 PIV/CAC + 卡。

点击“添加 PIV/CAC”后,你的浏览器会提示要你的 PIV/CAC 并要你选择一个证书。 + submit: 添加 PIV/CAC 卡 + registration: + labels: + email: 输入你的电邮地址 + email_language: 选择你的电邮语言偏好 + ssn: + show: 显示社会保障号码 + totp_setup: + totp_intro: 设立一个身份证实应用程序,以使用临时安全代码登录。 + totp_step_1: 给其一个昵称 + totp_step_1a: 如果你添加了一个以上的应用程序的话,你就能把它们分辨开来。 + totp_step_2: 打开你的身份证实应用程序 + totp_step_3: 用你的应用程序扫描这一二维码(QR)代码 + totp_step_4: 输入来自你应用程序的临时代码 + two_factor: + backup_code: 备用代码 + personal_key: 个人密钥 + try_again: 使用另一个电话号码 + validation: + required_checkbox: 请在这一框里打勾来继续。 + webauthn_platform_setup: + continue: 继续 + info_text: 你设置人脸或触摸解锁后需要再设置另一个身份证实方法。 + intro_html: +

就像解锁你的设备一样来做身份证实,无论是用你的面孔还是指纹、密码或者其他方法。

如果你使用iCloud + Keychain 或 Google Password Manager + 这样的密码管理器,管理器可能会要保存一个通行密钥。这会使你能使用那个密码管理器在任何设备上证实身份。%{link}

+ intro_link_text: 了解关于使用不同设备的更多信息。 + nickname: 设备昵称 + nickname_hint: 这样如果你添加了更多使用人脸或触摸解锁的设备的话,你就能把它们分辨开来。 + webauthn_setup: + intro: Use your physical security key to add an additional layer of protection + to your %{app_name} account to prevent unauthorized access. + learn_more: Learn more about security keys + nickname: 安全密钥昵称 + saving: 正在保存你的凭据… + set_up: Set up security key + step_1: Give it a nickname + step_1a: If you add more than one security key, you’ll know which one is which. + step_2: Insert a security key into your device + step_2_image_alt: A security key being inserted into the right side of a laptop + step_2_image_mobile_alt: A security key being inserted into the bottom of a smart phone + step_3: Set up your security key + step_3a: Click “set up security key” below and follow your browser’s instructions. diff --git a/config/locales/headings/zh.yml b/config/locales/headings/zh.yml new file mode 100644 index 00000000000..e3c10322476 --- /dev/null +++ b/config/locales/headings/zh.yml @@ -0,0 +1,72 @@ +--- +zh: + headings: + account: + activity: 活动 + authentication_apps: 身份证实应用程序 + connected_accounts: 你连接的账户 + devices: 设备 + events: 事件 + federal_employee_id: 政府雇员身份证件 + login_info: 你的账户 + reactivate: 重新激活你的账户 + two_factor: 你的身份证实方法。 + unphishable: 无法网络钓鱼 + verified_account: 验证过的账户 + verified_information: 验证过的信息 + add_email: 添加一个新电邮地址 + add_info: + phone: 添加一个电话号码 + cancellations: + prompt: 你确定要取消吗? + create_account_new_users: 为新用户创建一个账户 + create_account_with_sp: + sp_text: 正在使用 %{app_name} 使你能够安全可靠地登入账户。 + edit_info: + password: 更改密码 + phone: 管理你的电话设置 + passwords: + change: 更改密码 + confirm: 确认你目前密码以继续 + confirm_for_personal_key: 输入密码并获得一个新个人密钥 + forgot: 忘了你的密码? + piv_cac: + certificate: + bad: 你选择的 PIV/CAC 证书有误 + expired: 你选择的 PIV/CAC 证书已过期 + invalid: 你选择的 PIV/CAC 证书有误 + none: 我们在你的 PIV/CAC 卡上探查不到证书 + not_auth_cert: 请给你的PIV/CAC 卡选择另外一个证书 + revoked: 你选择的 PIV/CAC 证书已从你的卡上撤销 + unverified: 你选择的 PIV/CAC 证书有误 + did_not_work: 你的 PIV/CAC 卡没起作用 + token: + bad: 内部出错 + invalid: 你选择的 PIV/CAC 证书有误。 + missing: 内部出错 + piv_cac_login: + account_not_found: 你的 PIV/CAC 没有连接账户 + add: 把你的 PIV 或 CAC 设为一个双因素身份证实方法,这样你可以用其登录。 + new: 使用你的 PIV 或 CAC 登录 + success: 你已成功把 PIV/CAC 设为一个身份证实方法 + piv_cac_setup: + already_associated: 你提供的 PIV/CAC 与另外一个用户相关。 + new: 用你的 PIV/CAC 卡来保护你的账户安全 + redirecting: 重定向 + residential_address: 目前住宅地址 + session_timeout_warning: 需要更多时间? + sign_in_existing_users: 现有用户登录 + sign_in_with_sp: 登录以继续到 %{sp} + sign_in_without_sp: 登录 + sp_handoff_bounced: 连接 %{sp_name} 出了问题 + ssn: 社会保障号码 + state_id: 州政府颁发的身份证件 + totp_setup: + new: 添加一个身份证实应用程序 + verify: 验证你的信息 + verify_email: 检查你的电邮 + verify_personal_key: 验证你的个人密钥 + webauthn_platform_setup: + new: 添加人脸或触摸解锁 + webauthn_setup: + new: 添加你的安全密钥 diff --git a/config/locales/help_text/zh.yml b/config/locales/help_text/zh.yml new file mode 100644 index 00000000000..f41ebf055bc --- /dev/null +++ b/config/locales/help_text/zh.yml @@ -0,0 +1,22 @@ +--- +zh: + help_text: + requested_attributes: + address: 地址 + all_emails: 你账户上的电邮地址 + birthdate: 生日 + email: 电邮地址 + full_name: 姓名 + ial1_consent_reminder_html: 你每年都必须授权同意与 %{sp} 分享信息。我们将与 + %{sp} 分享你的信息来连接你账户。 + ial1_intro_html: 我们将与 %{sp} 分享你的信息来连接你账户。 + ial2_consent_reminder_html: + '%{sp} 需要知道你是谁才能连接你的账户。你每年都必须授权同意与 + %{sp} 分享已验证过的你的信息。我们会分享这些信息: ' + ial2_intro_html: '%{sp} 需要知道你是谁才能连接你的账户。我们会与 %{sp} 分享这些信息: ' + ial2_reverified_consent_info: '因为你重新验证了身份,我们需要得到你的许可才能与 %{sp} 分享该信息。 ' + phone: 电话号码 + social_security_number: 社会保障号码 + verified_at: 更新是在 + x509_issuer: PIV/CAC 发放方 + x509_subject: PIV/CAC 身份 diff --git a/config/locales/i18n/en.yml b/config/locales/i18n/en.yml index e1f2dc057ee..dd84553a32c 100644 --- a/config/locales/i18n/en.yml +++ b/config/locales/i18n/en.yml @@ -6,3 +6,4 @@ en: en: English es: Español fr: Français + zh: Chinese diff --git a/config/locales/i18n/es.yml b/config/locales/i18n/es.yml index 788a7662875..9055110e379 100644 --- a/config/locales/i18n/es.yml +++ b/config/locales/i18n/es.yml @@ -6,3 +6,4 @@ es: en: English es: Español fr: Français + zh: Chinese diff --git a/config/locales/i18n/fr.yml b/config/locales/i18n/fr.yml index 3f538e17e75..03132c69615 100644 --- a/config/locales/i18n/fr.yml +++ b/config/locales/i18n/fr.yml @@ -6,3 +6,4 @@ fr: en: English es: Español fr: Français + zh: Chinese diff --git a/config/locales/i18n/zh.yml b/config/locales/i18n/zh.yml new file mode 100644 index 00000000000..f46ae1a7ac4 --- /dev/null +++ b/config/locales/i18n/zh.yml @@ -0,0 +1,9 @@ +--- +zh: + i18n: + language: 语言 + locale: + en: 英文 + es: 西班牙文 + fr: 法文 + zh: Chinese diff --git a/config/locales/idv/zh.yml b/config/locales/idv/zh.yml new file mode 100644 index 00000000000..f97ab52ad66 --- /dev/null +++ b/config/locales/idv/zh.yml @@ -0,0 +1,272 @@ +--- +zh: + idv: + accessible_labels: + masked_ssn: 保护文本安全,从 %{first_number} 开始,到 %{last_number}结束 + buttons: + change_address_label: 更新地址 + change_label: 更新 + change_ssn_label: 更新社会保障号码 + change_state_id_label: 更新州颁发的身份证件 + continue_plain: 继续 + mail: + send: 要求发一封信 + cancel: + actions: + account_page: 到账户页面 + exit: 退出 %{app_name} + keep_going: 不是,继续 + start_over: 重新开始 + description: + account_page: 账户页面 + exit: + with_sp_html: + - 如果你退出 %{app_name} 并返回 %{sp_name},你尚未验证你的身份。 + - 你仍然会有 %{app_name} 账户。你可以在你的 %{account_page_link_html} 管理或删除账户。 + without_sp: + - 如果你退出身份验证并到你的账户页面,你尚未验证你的身份。 + - 你仍然会有 %{app_name} 账户。你可以在你的 %{account_page_text} 管理或删除账户。 + gpo: + continue: 继续来重新开始并从头开始验证身份。 + start_over: '如果你清除你的信息并重新开始:' + start_over_new_address: 要发信到另一个地址,则需要从头开始,用新地址来验证你的身份。 + warnings: + - 你信中的验证码会失效 + - 你将重新开始,从头开始验证身份 + hybrid: 如果你现在取消的话,会被提示切换回电脑继续验证你的身份。 + start_over: 如果你重新开始,就会从头重新开始这一流程。 + headings: + confirmation: + hybrid: 你已取消了在该手机上上传身份证件照片 + exit: + with_sp: 退出 %{app_name} 并返回 %{app_name} + without_sp: 退出身份验证并到你的账户页面 + prompt: + hybrid: 你确定要取消在该手机上上传身份证件照片吗? + standard: 取消验证身份? + start_over: 重新开始验证身份? + start_over: 重新开始验证身份 + errors: + incorrect_password: 你输入的密码不对。 + pattern_mismatch: + ssn: '输入 9 位数的社会保障号码' + zipcode: 输入 5 或 9 位的邮编 + zipcode_five: 输入 5 位的邮编 + failure: + attempts_html: + one: 出于安全考虑,你在网上添加身份证件只能再试一次了。 + other: 出于安全考虑,你在网上添加身份证件只能再试%{count} 次了。 + button: + try_online: 在网上再试一下 + warning: 再试一下。 + exceptions: + in_person_outage_error_message: + post_cta: + body: 与此同时,你仍然可以在 %{app_name}开始亲身验证身份流程,然后去邮局。如果你迫切需要得到服务,请直接联系该政府机构。 + title: 我们正在解决一个技术问题。你身份验证结果可能要到 %{date} 才能寄到。 + ready_to_verify: + body: 你仍然可以到邮局去完成验证你的身份。如果你迫切需要得到服务,请直接联系该政府机构。 + contact_html: + 如果你到 %{date}仍然没有收到有关验证结果的电邮 + 请联系 %{app_name}支持 % 。 + title: 我们正在解决一个技术问题。你身份验证结果可能要到 %{date} 才能通过电邮发给你。 + internal_error: 处理你的请求时内部出错。请再试一次。 + link: 请联系我们。 + post_office_search_error: 我们这边目前遇到技术困难。请再次尝试搜索一个邮局。如果该问题继续存在,请稍后回来。 + text_html: 请再试一次。如果这些错误一直出现,%{link_html}。 + exit: + with_sp: 退出 %{app_name} 并返回 %{app_name} + without_sp: 退出身份验证并到你的账户页面 + gpo: + rate_limited: + heading: 请稍后再试 + phone: + heading: 我们无法将该电话号码与其他记录相匹配 + jobfail: 出错了,我们现在无法处理你的请求。请再试一次。 + rate_limited: + body: 为了你的安全,我们限制你在网上尝试验证电话号码的次数。 + gpo: + button: 通过普通邮件验证 + heading: '我们无法通过电话验证你的身份。' + option_try_again_later_html: '取消并在 %{time_left} 后再试' + option_verify_by_mail_html: '通过普通邮件验证,这需要5 到 10 天' + options_header: '你可以:' + timeout: 我们请你验证自己信息的请求已过期。请再试一次。 + warning: + attempts_html: + one: 出于安全考虑,你只能再试一次了。 + other: 出于安全考虑,你只能再试%{count}次 了。 + gpo: + button: 通过普通邮件验证 + explanation: 如果你没有别的电话号码可以尝试,那请通过普通邮件验证。 + heading: 通过普通邮件验证 + how_long_it_takes_html: 这需要5 到 10 天。 + heading: 我们无法将你与该号码匹配。 + learn_more_link: '了解有关使用什么号码的更多信息。' + next_steps_html: '尝试 a另一个 你经常使用并用了很久的号码。 工作或住宅号码都行。' + try_again_button: '尝试另一个号码' + you_entered: '你输入了:' + sessions: + exception: 处理你的请求时内部出错。 + fail_html: '为了你的安全,我们限制你在网上尝试验证个人信息的次数。 %{timeout}后再试。' + heading: 我们找不到与你个人信息匹配的记录 + warning: 请检查一下你输入的信息,然后再试一下。常见错误包括社会保障号码或邮编不对。 + setup: + fail_date_html: 请给我们的联系中心在%{date_html} 之前打电话以继续验证你的身份。 + fail_html: Call %{contact_number} and provide them with the + error
code %{support_code}. + heading: 请给我们打个电话 + timeout: 目前处理你请求的时间超出正常等待时间。请再试一次。 + verify: + exit: Exit %{app_name} + fail_link_html: 在%{sp_name} 得到 + fail_text: 帮助以获得服务。 + heading: 我们无法验证你的身份证件。 + forgot_password: + link_text: 忘了密码? + modal_header: 你确定不记得密码吗? + reset_password: 重设密码 + try_again: 请再试一下。 + warnings: + - 如果你忘记了密码,则需重设并再次填写表格。 + - 你得重新输入个人信息,比如姓名、州政府颁发的身份证件等。 + form: + address1: 地址第 1 行 + address2: 地址第 2 行 + city: 城市 + dob: 生日 + first_name: 名 + id_number: 身份证件号 + issuing_state: 颁发州 + last_name: 姓 + password: 密码 + ssn: 社会保障号码 + ssn_label: 社会保障号码 + state: 州 + zipcode: 邮编 + gpo: + alert_info: '我们把带有你验证码的信件寄到了:' + alert_rate_limit_warning_html: 你现在不能再要求发信了。你此前曾在%{date_letter_was_sent} 要求过信件。 + clear_and_start_over: 清除你的信息并重新开始。 + did_not_receive_letter: + form: + instructions: 如果你收到了信,请输入信中由 10 个字符组成的代码。 + intro: + be_patient_html: 请注意信件最多需要 10 天才能送到。感谢你的耐心。 + request_new_letter_link: 要求再发一封信 + request_new_letter_prompt_html: 如果你尚未收到信,可以 %{request_new_letter_link}。 + title: 没收到信? + form: + instructions: 输入你收到信中的由 10 个字符组成的代码。 + otp_label: 验证码 + submit: 确认账户 + title: 确认你的账户 + intro_html: + '

如果你收到了信件,请在下面输入你的一次性代码 。

如果你的信件还没到,请耐心等待,因为信件最多需要 + 10 天才能送到。感谢你的耐心。

' + request_another_letter: + button: 要求再发一封信 + instructions_html: 如果你目前的信有问题或者从未收到,请再要求发一封信。信件需要5 到 10 天到达。 + learn_more_link: 对通过邮件验证你地址获得更多了解 + title: 要求再发一封信? + return_to_profile: 返回你的用户资料 + title: 欢迎回来 + wrong_address: 地址不对? + images: + come_back_later: 带有打勾符的信件 + messages: + activated_html: 你的身份已经验证。如要更改已验证过的你的信息,请 %{link_html}。 + activated_link: 联系我们 + clear_and_start_over: 清除我的信息并重新开始。 + come_back_later_html:

美国邮局平信一般需要 5 到 10 天 送达。

+ 你的信件到达后,请登录 %{app_name},并按照提示输入你的验证码

。 + come_back_later_no_sp_html: 你目前可以返回你的%{app_name} 账户 了。 + come_back_later_password_html: 请勿忘记密码。
如果你重设密码,信件中的一次性代码就会失效,你就需要再次验证身份。 + come_back_later_sp_html: 你目前可以返回 %{sp} 了。 + confirm: 我们对你验证过的信息做了安全处理 + enter_password: + by_mail_password_reminder_html: 记住你的密码。 如果你随后重设密码的话,信中的验证码就不会奏效。 + message: '%{app_name}会用你的密码对你的账户加密。加密意味着你的信息很安全,而且只有你能够访问或变更你的信息。' + phone_verified: 我们验证了你的电话号码 + gpo: + address_on_file: '我们会向你早些时候验证过的地址发送一封信。' + another_letter_on_the_way: 我们正在再给你发送一封信。 + info_alert: 你得等到收到信件后才能完成验证你的身份。 + learn_more_verify_by_mail: 对通过邮件验证你地址获得更多了解 + letter_on_the_way: 我们正在给你发信。 + resend: 给我另外发送一封信 + start_over_html: 如果该地址不对,你需要%{start_over_link_html}。 + start_over_link_text: 重新开始并用你新地址进行验证。 + timeframe_html: 你会在 5 到 10 天 里收到带有验证码 的信。 + otp_delivery_method_description: 如果你在上面输入的是座机电话,请在下边选择“接听电话”。 + phone: + alert_html: '输入一个这样的电话号码: ' + description: 我们会将该号码与记录核对并给你发个一次性代码。这是为了帮助你验证身份。 + failed_number: + alert_text: 我们无法将你与该号码匹配。 + gpo_alert_html: 试试 另一个 号码或者%{link_html}。 + gpo_verify_link: 通过普通邮件验证 + try_again_html: 试试 另一个 号码。 + rules: + - 美国的(包括美国属地) + - 你的主要号码(你最常用的) + return_to_profile: '‹ 返回你的 %{app_name} 用户资料' + select_verification_with_sp: 为了保护你不受身份欺诈,我们会联系你确认该 %{sp_name} 账户是真实的。 + select_verification_without_sp: 为了保护你不受身份欺诈,我们会联系你确认该账户是真实的。 + sessions: + enter_password_message: 你重新输入密码时, %{app_name} 会保护你给我们的信息,这样只有你能访问这些信息。 + no_pii: 测试站点 - 请勿使用真实个人信息(仅为演示目的) - 测试站点 + verify_info: 我们从你的身份证件上读取你的信息。提交进行验证之前请检查一下并做出必要更新。 + verifying: 验证中。。。 + titles: + activated: 你的身份已验证 + come_back_later: 你的信件已寄出。 + enter_password: 审阅并提交 + mail: + verify: 验证你的地址 + otp_delivery_method: 我们应该如何发送代码? + session: + enter_password: 重新输入你的%{app_name}密码 + enter_password_letter: 重新输入你的%{app_name}密码来给你发信 + unavailable: '我们正在争取解决错误。' + troubleshooting: + headings: + need_assistance: '需要立即帮助?以下是如何得到帮助的步骤:' + options: + contact_support: 联系 %{app_name} 支持 + doc_capture_tips: 拍出你身份证件清晰照片的提示。 + get_help_at_sp: 在 %{sp_name}得到帮助 + learn_more_verify_by_mail: 对通过邮件验证你地址获得更多了解 + learn_more_verify_by_phone: 了解有关使用什么号码的更多信息 + learn_more_verify_by_phone_in_person: 对验证你的电话号码获得更多了解 + learn_more_verify_in_person: 对亲身验证获得更多了解 + supported_documents: 了解有关哪些身份证件可被接受的更多信息 + verify_by_mail: 使用邮件来验证地址 + unavailable: + exit_button: '退出 %{app_name}' + idv_explanation: + with_sp_html: '%{sp}需要确保你是你,而不是别人冒充你。' + without_sp: '你试图访问的机构需要确保你是你,而不是别人冒充你。' + next_steps_html: '%{status_page_link_html} 或者退出 %{app_name},稍后再试。' + status_page_link: '在我们的状态页面获得最新信息。' + technical_difficulties: 很遗憾,我们这边现在遇到技术困难,目前无法验证你的身份。 + warning: + attempts_html: + one: 出于安全考虑,你只能再试一次了。 + other: 出于安全考虑,你只能再试 %{count} 次 了 。 + sessions: + heading: 我们找不到与你个人信息匹配的记录 + state_id: + cancel_button: 退出 %{app_name} + explanation: | + 遗憾的是,处理来自你所在州的身份证件时我们遇到技术困难,目前无法验证你的信息。 + heading: 我们正在争取解决错误。 + next_steps: + items_html: + - S现在再试一次或者 + - 退出 %{app_name},稍后再试 + preamble: '你可以:' + try_again_button: 再试一下。 + welcome: + no_js_header: 你必须启用 JavaScript 以验证身份。 + no_js_intro: '%{sp_name} 需要你验证身份。你需要启用JavaScript 以继续这一流程。' diff --git a/config/locales/image_description/zh.yml b/config/locales/image_description/zh.yml new file mode 100644 index 00000000000..15984ae74f0 --- /dev/null +++ b/config/locales/image_description/zh.yml @@ -0,0 +1,17 @@ +--- +zh: + image_description: + camera_mobile_phone: 手机上相机在闪光 + delete: 红色垃圾桶 + error: 红色错误 x + error_lock: 红色错误锁 + info_pin_map: 地图图钉图像 + info_question: 蓝色问号 + laptop: 手提电脑 + laptop_and_phone: 手提电脑和电话 + personal_key: 个人密钥 + phone_icon: Image of a phone + post_office: 邮局 + totp_qrcode: 身份证实应用程序的二维码(QR)代码 + us_flag: 美国国旗 + warning: 黄色警告标志 diff --git a/config/locales/in_person_proofing/zh.yml b/config/locales/in_person_proofing/zh.yml new file mode 100644 index 00000000000..72859cb0e5b --- /dev/null +++ b/config/locales/in_person_proofing/zh.yml @@ -0,0 +1,138 @@ +--- +zh: + in_person_proofing: + body: + barcode: + cancel_link_text: 取消你的条形码 + close_window: 你现在可以关闭这一窗口。 + deadline: 你必须在 %{deadline}之前去任何参与邮局。 + deadline_restart: 如果你在截止日期后才去邮局,你的信息不会被存储,你又得从头开始这一过程。 + email_sent: 我们已将该信息发送到你用来登录的电邮。 + learn_more: 了解有关携带物品的更多信息 + location_details: 详细地址信息 + questions: 有问题吗? + retail_hours: 营业时间 + retail_hours_closed: 已关闭 + return_to_partner_html: 你现在可以 %{link_html}来完成你可做的任何随后步骤,直到你的身份得到验证。 + return_to_partner_link: 登出 %{app_name} 并返回 %{app_name} + what_to_expect: 在邮局会发生什么 + cta: + button: 尝试亲身去 + prompt_detail: 你也许可以到附近一个参与本项目的邮局去亲身验证你的身份证件。 + expect: + heading: 去邮局后会发生什么 + info: 你去邮局后 24 小时内,我们会给你发电邮,告诉你是否成功验证了身份。 + location: + distance: + one: '距离你 %{count} 英里' + other: '距离你 %{count} 英里' + heading: 邮局信息 + info: 验证你的身份无需做预约。你可以去任何参与邮局。 + inline_error: 请输入正确地址,包括城市、州和邮编 + location_button: 选择 + po_search: + address_label: 地址 + address_search_hint: '举例:1234 N Example St., Allentown, PA 12345' + address_search_label: 输入地址来寻找你附件的邮局。 + city_label: 城市 + is_searching_message: 正在寻找邮局。。。 + none_found: 抱歉,%{address}方圆 50 英里没有参与本项目的邮局。 + none_found_tip: 你可以换个地址搜索,或添加你身份证件照片,再次尝试在网上验证你的身份。 + po_search_about: 你能在当地任何一个参与的美国邮局亲身去验证身份。 + results_description: + one: '%{address}方圆 50 英里有一个参与本项目的邮局。' + other: '%{address}方圆 50 英里有 %{count} 个参与本项目邮局。' + results_instructions: 选择下面一个邮局,或者换个地址做搜索。 + search_button: 搜索 + state_label: 州 + zipcode_label: 邮编 + retail_hours_heading: 营业时间 + retail_hours_sat: '周六:' + retail_hours_sun: '周日:' + retail_hours_weekday: '周一到周五:' + selection: '这是你选择的地点:' + prepare: + privacy_disclaimer: '%{app_name} 是一个安全的政府网站。我们和美国邮局使用你的数据来验证你的身份。' + privacy_disclaimer_link: 了解有关隐私和安全的更多信息。 + privacy_disclaimer_questions: 有问题吗? + verify_step_about: '完成以下步骤来生成你带去邮局的条形码。' + verify_step_enter_phone: 输入你的主要电话号码或者你最常使用的号码。 + verify_step_enter_pii: 输入你的姓名、生日、州政府颁发的身份证件号、地址以及社会保障号码。 + verify_step_post_office: 找到你附件的参与邮局。 + state_id: + alert_message: '州政府颁发给你的身份证件必须尚未过期。系统接受的身份证件包括:' + id_types: + - 州驾照 + - 州非驾照身份卡 + info_html: 输入 与州政府颁发的身份证件上完全一致的信息 + 我们将使用该信息来确认与你亲身出现所持身份证件上信息的一致性。 + learn_more_link: 了解更多有关哪些身份证件可被接受的信息。 + questions: 有问题吗? + form: + address: + errors: + unsupported_chars: '我们的系统无法读取以下字符: %{char_list}. 请替换那些字符后再试一次。' + state_prompt: '- 选择 -' + state_id: + address1: 地址第 1 行 + address1_hint: '举例: 150 Calle A Apt 3' + address2: 地址第 2 行 + address2_hint: '举例: URB Las Gladiolas or COND Miraflor' + city: 城市 + date_hint: + day: '举例:28' + month: '举例:4' + year: '举例:1986' + dob: 生日 + dob_hint: '举例:4 28 1986' + errors: + unsupported_chars: '我们的系统无法读取以下字符:%{char_list}.请使用你身份证件上的字符再试一次。' + first_name: 名 + identity_doc_address_state: 州 + identity_doc_address_state_prompt: '- 选择 -' + last_name: 姓 + memorable_date: + errors: + date_of_birth: + missing_month_day_year: 输入生日 + range_min_age: 你年龄必须超过 13 岁才能使用 %{app_name} + range_overflow: 输入过去的一个日期 + same_address_as_id: 你目前所住地址是州政府颁发的身份证件上所列地址吗? + same_address_as_id_no: 不是,我住在一个不同的地址。 + same_address_as_id_yes: 是的,我住在州政府颁发的身份证件上所列地址。 + state_id_jurisdiction: 颁发州 + state_id_jurisdiction_hint: 这是颁发你身份证件的州 + state_id_jurisdiction_prompt: '- 选择 -' + state_id_number: 身份证件号 + state_id_number_florida_hint_html: This is the number on your ID with one letter + and 12 numbers. Example: D123-456-78-901-2 + state_id_number_hint: '可以包含字母、数字以及以下符号:' + state_id_number_hint_asterisks: 星号 + state_id_number_hint_dashes: 横杠 + state_id_number_hint_forward_slashes: 前斜杠 + state_id_number_hint_spaces: 空格 + state_id_number_texas_hint: 这是你身份证件上的8位数在这一字段中只输入数字 + zipcode: 邮编 + headings: + address: 输入你当前住宅地址 + barcode: 到邮局出示这一条形码和你州政府颁发的身份证件来完成验证身份。 + cta: 尝试亲身去验证身份证件 + id_address: 你身份证件上的地址 + po_search: + location: 找到一个参与本项目的邮局 + prepare: 亲身去验证身份 + state_id_milestone_2: 输入你州政府颁发身份证件上的信息。 + switch_back: 切换回你的电脑,来准备亲身去验证身份。 + update_address: 更新你当前地址 + update_state_id: 更新你身份证件上的信息 + process: + barcode: + caption_label: 注册代码 + heading: 出示你的 %{app_name} 条形码 + info: 邮局工作人员需要扫描该页顶部的条形码你可以把该页打印出来,或在你的移动设备上显示。 + state_id: + heading: 出示你州驾照或州非驾照身份卡。 + info: 该证件必须在有效期内。我们目前不接受任何其他形式的身份证件,比如护照和军队身份证件。 + what_to_do: + heading: 请排在任何一队里 + info: 告诉邮局工作人员你是为了%{app_name}验证你的身份。 diff --git a/config/locales/instructions/zh.yml b/config/locales/instructions/zh.yml new file mode 100644 index 00000000000..ffc0b3f2843 --- /dev/null +++ b/config/locales/instructions/zh.yml @@ -0,0 +1,78 @@ +--- +zh: + instructions: + account: + reactivate: + begin: 我们开始吧。 + explanation: '你设立账户时,我们给了你一个单词清单并请你将其存放在一个安全的地方。清单像这样:' + intro: 我们采取额外步骤来保护你个人信息的安全和私密,所以重设密码需要多花点精力。 + modal: + copy: 如果你没有个人密钥,则需要重新验证你的身份。 + heading: 没有个人密钥? + with_key: 你有个人密钥吗? + forgot_password: + close_window: 重设你的密码后,你就可以关闭这个浏览器窗口。 + go_back_to_mobile_app: 要继续的话,请回到 %{friendly_name}应用程序并登录。 + mfa: + authenticator: + confirm_code_html: 输入来自你身份证实应用程序的代码。如果你在应用程序中设了几个账户,请输入与在 %{app_name_html}对应的代码。 + manual_entry: 或者动手将这个密码输入你的身份证实应用程序。 + piv_cac: + account_not_found_html: + '用你的电邮地址和密码

%{sign_in}。然后把你的 PIV/CAC + 添加到账户。

没有%{app_name} 账户? + %{create_account}

' + add_from_sign_in_html: '说明: 看到“添加 + PIV/CAC”时插入你的 PIV or CAC 。你将需要选择一个证书 + (恰当的证书可能会有你的名字)而且输入你的个人识别号码(PIN) (你的个人识别号码(PIN)是在设置 + PIV/CAC 时设立的)。' + already_associated_html: + 请从另一个 PIV/CAC 选择证书,联系管理员以保证你的 PIV/CAC 是最新的。如果你认为这是一个错误, + %{try_again_html}。 + back_to_sign_in: 返回去登录 + confirm_piv_cac: 提供与你账户相关的 PIV/CAC。 + did_not_work_html: 请 %{please_try_again_html}。如果这一问题持续的话,请联系你机构的管理员。 + http_failure: 服务器反应时间过长。请再试一次。 + no_certificate_html: 请确保你的 PIV/CAC 是连上的并 %{try_again_html}。如果这一问题持续的话,请联系你机构的管理员。 + not_auth_cert_html: 你选择的证书对这个账户无效。请用另外一个证书 %{please_try_again_html}。如果这一问题持续的话,请联系你机构的管理员。 + please_try_again: 再试一次 + sign_in_html: 确保 你有 %{app_name} 账户 而且 PIV/CAC + 已被你设置为一个双因素身份证实方法。 + step_1: 给它一个昵称 + step_1_info: 这样如果你添加了一个以上 PIV/CA 话,你就能把它们分辨开来。 + step_2: 把 PIV/CAC 插入读卡器 + step_3: 添加 PIV/CAC + step_3_info_html: 你将需要选择一个证书 + (恰当的证书可能会有你的名字)而且输入你的个人识别号码(PIN) (你的个人识别号码(PIN)是在设置 + PIV/CAC 时设立的)。 + try_again: 再试一次 + sms: + number_message_html: 我们把带有一次性代码的短信发到了 %{number_html}。这一代码 %{expiration} 分钟后会作废。 + voice: + number_message_html: 我们给 %{number_html}打了电话告知一次性代码。这一代码 %{expiration} 分钟后会作废。 + webauthn: + confirm_webauthn: 提供与你账户相关的安全密钥。 + confirm_webauthn_platform_html: +

你可以像解锁你的设备一样来做身份证实,无论是用你的面孔还是指纹、密码或者其他方法。

+

如果你用一个密码管理器设置了人脸或触摸解锁,则可以从使用那一密码管理器的任何设备进行身份证实。否则的话,使用你设置人脸或触摸解锁的同一设备。

+ webauthn_platform: + learn_more_help: 了解更多有关人脸或触摸解锁的信息 + wrong_number: 输入的电话号码不对? + password: + forgot: 不知道你的密码?确认你的电邮地址后重设密码。 + help_text: 避免重复使用你其他网上账户(比如银行、电邮和社交媒体)的密码。请勿包括你电邮地址中的单词。 + help_text_header: 密码安全提示 + info: + lead_html: + 你的密码必须至少有 %{min_length} 个字符 。请勿使用常见短语或重复性字符,比如 abc + 或 111。 + password_key: 你在这个账户验证了身份后,需要一个 16 字符的个人密钥来重设密码。如果你没有,仍然可以重设密码然后再验证身份。 + strength: + 0: 太弱 + 1: 弱 + 2: 一般 + 3: 好 + 4: 棒! + intro: '密码强度: ' + sp_handoff_bounced: 你登录成功了,但 %{sp_name} 将你送回到 %{app_name}。请联系 %{sp_link} 寻求帮助。 + sp_handoff_bounced_with_no_sp: 你的服务提供商 diff --git a/config/locales/links/zh.yml b/config/locales/links/zh.yml new file mode 100644 index 00000000000..6ecf8a4548c --- /dev/null +++ b/config/locales/links/zh.yml @@ -0,0 +1,27 @@ +--- +zh: + links: + account: + reactivate: + with_key: 我有密钥 + without_key: 我没有密钥 + back_to_sp: 返回 %{sp} + cancel: 取消 + cancel_account_creation: '‹ 取消设立账户' + contact: 联系 + continue_sign_in: 继续登录 + create_account: 设立账户 + exit_login: 退出 %{app_name} + go_back: 返回 + help: 帮助 + new_tab: '(打开新窗口)' + passwords: + forgot: 忘了你的密码? + privacy_policy: 隐私与安全 + resend: 重发 + reverify: 请再验证你的身份。 + sign_in: 登录 + sign_out: 登出 + two_factor_authentication: + send_another_code: 发送另一个代码 + what_is_totp: 什么是身份证实应用程序? diff --git a/config/locales/mailer/zh.yml b/config/locales/mailer/zh.yml new file mode 100644 index 00000000000..d7960c03713 --- /dev/null +++ b/config/locales/mailer/zh.yml @@ -0,0 +1,11 @@ +--- +zh: + mailer: + about: 关于 %{app_name} + email_reuse_notice: + subject: 该电邮地址已与一个账户相关。 + help_html: 如果需要帮助,请访问 %{link_html} + logo: '%{app_name} 标志' + no_reply: 请勿对该信息作出回复。 + privacy_policy: 隐私政策 + sent_at: 在 %{formatted_timestamp}时发出 diff --git a/config/locales/mfa/zh.yml b/config/locales/mfa/zh.yml new file mode 100644 index 00000000000..07e01900f1a --- /dev/null +++ b/config/locales/mfa/zh.yml @@ -0,0 +1,13 @@ +--- +zh: + mfa: + account_info: 添加另外一个身份证实方法可以预防你在失去其他方法后被锁在账户外。 + add: 添加另外一个方法 + info: 选择一个多因素身份证实方法来增加更多一层安全防线。我们建议你选择至少两个不同方法,以防你丢失其中一个。 + recommendation: We recommend you select at least two different options in case + you lose one of your methods. + second_method_warning: + link: 添加第二个身份证实方法。 + text: 如果你丢失唯一的身份证实方法,就不得不删除你的账户并从头开始。 + skip: 现在先跳过 + webauthn_platform_message: 添加另一个身份证实方法,以防你无法使用第一个方法。 diff --git a/config/locales/notices/zh.yml b/config/locales/notices/zh.yml new file mode 100644 index 00000000000..ff8a01b5026 --- /dev/null +++ b/config/locales/notices/zh.yml @@ -0,0 +1,48 @@ +--- +zh: + notices: + account_reactivation: 好!你有个人密钥。 + authenticated_successfully: 成功验证。 + backup_codes_configured: 备用代码加到了你账户。 + backup_codes_deleted: 备用代码已从你账户删除。 + dap_participation: 我们参与美国政府的数据分析计划。参见 analytics.usa.gov 的数据 + forgot_password: + first_paragraph_end: 带有重设你密码的链接。跟随链接继续重设你的密码。 + first_paragraph_start: 我们已发电邮到 + no_email_sent_explanation_start: 没有收到电邮? + resend_email_success: 我们发了另外一个重设密码的电邮。 + password_changed: 你更改了密码。 + phone_confirmed: 你账户添加了一个电话。 + piv_cac_configured: 你账户添加了一个 PIV/CAC 卡。 + privacy: + privacy_act_statement: 隐私法声明 + security_and_privacy_practices: 安全实践和隐私法声明 + resend_confirmation_email: + success: 我们发送了另外一个确认电邮。 + session_cleared: 为了你的安全,如果你在 %{minutes} 分钟内不移动到一个新页面,我们会清除你输入的内容。 + session_timedout: 我们已将你登出。为了你的安全,如果你在 %{minutes} 分钟内不移动到一个新页面,%{app_name} 会结束你此次访问。 + signed_up_and_confirmed: + first_paragraph_end: 带有重设你密码的链接。点击链接去继续把这个电邮添加到你的账户。 + first_paragraph_start: 我们已发电邮到 + no_email_sent_explanation_start: 没有收到电邮? + signed_up_but_unconfirmed: + first_paragraph_end: 带有确认你电邮的链接。点击链接来继续设立你的账户。 + first_paragraph_start: 我们已发电邮到 + resend_confirmation_email: Resend the confirmation email + timeout_warning: + partially_signed_in: + continue: 继续登录 + live_region_message_html: '%{time_left_in_session_html} 后你会被登出。选择“保持我登录状态”保持登录;选择“把我登出”来登出。' + message_html: 为了你的安全, %{time_left_in_session_html} 后我们将取消你的登录。 + sign_out: 取消登录 + signed_in: + continue: 保持我登录状态 + live_region_message_html: '%{time_left_in_session_html} 后你会被登出。选择“保持我登录状态”保持登录;选择“把我登出”来登出。' + message_html: 为了你的安全,我们会在 %{time_left_in_session_html} 后将你登出,除非你告诉我们不要这样做。 + sign_out: 把我登出 + totp_configured: 你账户添加了一个身份证实应用程序。 + use_diff_email: + link: 使用一个不同的电邮地址 + text_html: 或者,%{link_html} + webauthn_configured: 你账户添加了一个安全密钥。 + webauthn_platform_configured: 你用了自己设备的屏幕锁定给账户添加了人脸或触摸解锁。 diff --git a/config/locales/openid_connect/zh.yml b/config/locales/openid_connect/zh.yml new file mode 100644 index 00000000000..1db1b4663f8 --- /dev/null +++ b/config/locales/openid_connect/zh.yml @@ -0,0 +1,51 @@ +--- +zh: + openid_connect: + authorization: + errors: + bad_client_id: Bad client_id + invalid_verified_within_duration: + one: value must be at least %{count} day or older + other: value must be at least %{count} days or older + invalid_verified_within_format: Unrecognized format for verified_within + missing_ial: Missing a valid IAL level + no_auth: The acr_values are not authorized + no_valid_acr_values: No acceptable acr_values found + no_valid_scope: No valid scope values found + no_valid_vtr: No acceptable vots found + prompt_invalid: No valid prompt values found + redirect_uri_invalid: redirect_uri is invalid + redirect_uri_no_match: redirect_uri does not match registered redirect_uri + unauthorized_scope: Unauthorized scope + logout: + confirm: Yes, sign out of %{app_name} + deny: No, go to my account page + errors: + client_id_invalid: client_id was not recognized + client_id_missing: client_id is missing + id_token_hint: id_token_hint was not recognized + id_token_hint_present: This application is misconfigured and should not be + sending id_token_hint. Please send client_id instead. + no_client_id_or_id_token_hint: This application is misconfigured and must send + either client_id or id_token_hint. + heading: Do you want to sign out of %{app_name}? + heading_with_sp: Do you want to sign out of %{app_name} and return to + %{service_provider_name}? + token: + errors: + expired_code: is expired + invalid_aud: Invalid audience claim, expected %{url} + invalid_authentication: Client must authenticate via PKCE or private_key_jwt, + missing either code_challenge or client_assertion + invalid_code: is invalid because doesn’t match any user. Please see our + documentation at https://developers.login.gov/oidc/#token + invalid_code_verifier: code_verifier did not match code_challenge + invalid_iat: iat must be an integer or floating point Unix timestamp + representing a time in the past + invalid_signature: Could not validate assertion against any registered public keys + user_info: + errors: + malformed_authorization: Malformed Authorization header + no_authorization: No Authorization header provided + not_found: Could not find authorization for the contents of the provided + access_token or it may have expired diff --git a/config/locales/pages/zh.yml b/config/locales/pages/zh.yml new file mode 100644 index 00000000000..e779c72a259 --- /dev/null +++ b/config/locales/pages/zh.yml @@ -0,0 +1,6 @@ +--- +zh: + pages: + page_took_too_long: + body: 你也许想等几分钟后再试。(503) + header: 服务器处理你请求的时间过长。 diff --git a/config/locales/report_mailer/zh.yml b/config/locales/report_mailer/zh.yml new file mode 100644 index 00000000000..c92720f7e97 --- /dev/null +++ b/config/locales/report_mailer/zh.yml @@ -0,0 +1,7 @@ +--- +zh: + report_mailer: + deleted_accounts_report: + issuers: 发放方 + name: 姓名 + subject: 已删除账户报告 diff --git a/config/locales/risc/zh.yml b/config/locales/risc/zh.yml new file mode 100644 index 00000000000..a91205af2b2 --- /dev/null +++ b/config/locales/risc/zh.yml @@ -0,0 +1,18 @@ +--- +zh: + risc: + security_event: + errors: + alg_unsupported: unsupported algorithm, must be signed with %{expected_alg} + aud_invalid: invalid aud claim, expected %{url} + event_type_missing: missing event + event_type_unsupported: unsupported event type %{event_type} + exp_present: SET events must not have an exp claim + jti_not_unique: jti was not unique + jti_required: jti claim is required + jwt_could_not_parse: could not parse JWT + no_public_key: could not load public key for issuer + sub_not_found: invalid event.subject.sub claim + sub_unsupported: top-level sub claim is not accepted + subject_type_unsupported: subject_type must be %{expected_subject_type} + typ_error: typ header must be %{expected_typ} diff --git a/config/locales/saml_idp/zh.yml b/config/locales/saml_idp/zh.yml new file mode 100644 index 00000000000..544ce88de0d --- /dev/null +++ b/config/locales/saml_idp/zh.yml @@ -0,0 +1,10 @@ +--- +zh: + saml_idp: + auth: + error: + title: 错误 + shared: + saml_post_binding: + heading: 提交来继续 + no_js: 你浏览器中的 JavaScript 似乎已关闭。该步骤通常会自动发生,但因为你把 JavaScript 关闭,请点击提交按钮来继续登入或登出。 diff --git a/config/locales/service_providers/zh.yml b/config/locales/service_providers/zh.yml new file mode 100644 index 00000000000..a6b14bcdf6f --- /dev/null +++ b/config/locales/service_providers/zh.yml @@ -0,0 +1,11 @@ +--- +zh: + service_providers: + errors: + generic_sp_name: 该机构 + inactive: + button_text: 查看我的 %{app_name} 账户 + heading: '%{sp_name} 不再使用 %{app_name}' + instructions: '%{sp_name}不再用 %{app_name} 为其网站提供登录服务。如果你设立了 %{app_name} + 账户的话,账户仍然有效,可用来访问其他参与本项目的政府网站。' + instructions2: 请访问该机构的网站来联系他们并获得更多信息。 diff --git a/config/locales/shared/zh.yml b/config/locales/shared/zh.yml new file mode 100644 index 00000000000..4ceab225ec3 --- /dev/null +++ b/config/locales/shared/zh.yml @@ -0,0 +1,17 @@ +--- +zh: + shared: + banner: + fake_site: 美国政府的一个示范网站 + gov_description_html: .gov 网站属于一个美国官方政府机构。 + gov_heading: 官方网站使用 .gov + how: 这里告诉你如何知道 + landmark_label: 官方政府网站 + lock_description: 锁上的锁头 + official_site: 美国政府的一个官方网站 + secure_description_html: 一把 ( %{lock_icon} )或者 + https:// 意味着你已安全连接到 .gov 网站。只在官方、安全的网站上分享敏感信息。 + secure_heading: 安全的 .gov 网站使用 HTTPS + footer_lite: + gsa: 美国联邦总务管理局 + skip_link: 跳到主要内容 diff --git a/config/locales/sign_up/zh.yml b/config/locales/sign_up/zh.yml new file mode 100644 index 00000000000..65563566f91 --- /dev/null +++ b/config/locales/sign_up/zh.yml @@ -0,0 +1,10 @@ +--- +zh: + sign_up: + agree_and_continue: 同意并继续 + cancel: + success: 你的账户已被删除。我们没有存储你的信息。 + warning_header: '如果你现在取消:' + completed: + smiling_image_alt: 面带笑容的一个人加上绿勾说明成功了 + terms: 我已阅读并接受 %{app_name} diff --git a/config/locales/simple_form/zh.yml b/config/locales/simple_form/zh.yml new file mode 100644 index 00000000000..f438d57727f --- /dev/null +++ b/config/locales/simple_form/zh.yml @@ -0,0 +1,11 @@ +--- +zh: + simple_form: + error_notification: + default_message: '请仔细阅读以下问题:' + 'no': '不' + required: + html: '' + mark: '' + text: 该字段必需做 + 'yes': '对' diff --git a/config/locales/step_indicator/zh.yml b/config/locales/step_indicator/zh.yml new file mode 100644 index 00000000000..425f91f461a --- /dev/null +++ b/config/locales/step_indicator/zh.yml @@ -0,0 +1,18 @@ +--- +zh: + step_indicator: + accessible_label: 步骤进展 + flows: + idv: + find_a_post_office: 找一个邮局 + get_a_letter: 接收一封信 + getting_started: 开始 + go_to_the_post_office: 去邮局 + secure_account: 保护你的账户安全 + verify_id: 验证你的身份证件 + verify_info: 验证你的信息 + verify_phone_or_address: 验证电话或地址 + status: + complete: 完成了 + current: 目前步骤 + not_complete: 未完成 diff --git a/config/locales/telephony/zh.yml b/config/locales/telephony/zh.yml new file mode 100644 index 00000000000..bc581007251 --- /dev/null +++ b/config/locales/telephony/zh.yml @@ -0,0 +1,45 @@ +--- +zh: + telephony: + account_deleted_notice: This text message confirms you have deleted your %{app_name} account. + account_reset_cancellation_notice: 删除你 %{app_name} 账户的请求已被取消。 + account_reset_notice: 按照你的请求,你的 %{app_name} 账户会在 24 小时后删除。不想删除你的账户?登入你的 %{app_name} 账户去取消。 + authentication_otp: + sms: |- + %{app_name}: 你的一次性代码是 %{code}。此代码在 %{expiration} 分钟后作废。请勿与任何人分享此代码。 + + @%{domain} #%{code} + voice: 你好! 你的 6-%{format_type} %{app_name} 一次性代码是 %{code}。你的一次性代码是 + ,%{code}。重复一下,你的一次性代码是 %{code}。此代码 %{expiration} 分钟后会作废。 + confirmation_ipp_enrollment_result: + sms: |- + %{app_name}: 你在 %{proof_date}去了邮局。查查你的电邮看一下结果。不是你?马上报告此事:%{contact_number}。关于: %{reference_string} + confirmation_otp: + sms: |- + %{app_name}: 你的一次性代码是 %{code}。此代码在 %{expiration} 分钟后作废。请勿与任何人分享此代码。 + + @%{domain} #%{code} + voice: 你好! 你的 6-%{format_type} %{app_name} 一次性代码是 %{code}。你的一次性代码是 + ,%{code}。重复一下,你的一次性代码是 %{code}。此代码 %{expiration} 分钟后会作废。 + doc_auth_link: |- + %{app_name}: %{link} 你在验证身份以访问 %{sp_or_app_name}。拍张你身份证件的照片以继续。 + error: + friendly_message: + daily_voice_limit_reached: 你的一次性代码未能发出,因为已超出 24 小时内拨打这个电话号码的最多次数。你可以请求通过短信发送代码,或使用另外一个号码来接听电话。 + duplicate_endpoint: 输入的电话号码有误。 + generic: 你的一次性代码未能发送。 + invalid_calling_area: 不支持拨打那个电话号码。如果你有可接受短信的电话,请尝试用短信。 + invalid_phone_number: 输入的电话号码有误。 + opt_out: 输入的电话号码选择不接受短信。 + permanent_failure: 输入的电话号码有误。 + rate_limited: 那个号码目前短信量太大。 请稍后再试。 + sms_unsupported: 输入的电话号码不支持短信。尝试接听电话选项。 + temporary_failure: 我们目前遇到技术困难。 请稍后再试。 + timeout: 服务器反应时间过长。请再试一次。 + unknown_failure: 我们目前遇到技术困难。 请稍后再试。 + voice_unsupported: 电话号码有误。检查一下你是否输入了正确的国家代码或区域代码。 + format_type: + character: 字符 + digit: 数码 + personal_key_regeneration_notice: 已给你的 %{app_name} 账户发放了一个新个人密钥。如果不是你,请重设你的密码。 + personal_key_sign_in_notice: 你的个人密钥刚被用来登录你的 %{app_name} 账户。如果不是你,请重设你的密码。 diff --git a/config/locales/time/zh.yml b/config/locales/time/zh.yml new file mode 100644 index 00000000000..9faaf23b78f --- /dev/null +++ b/config/locales/time/zh.yml @@ -0,0 +1,41 @@ +--- +zh: + date: + day_names: + - # empty item to have correct weekday offset for %A + - 星期一 + - 星期二 + - 星期三 + - 星期四 + - 星期五 + - 星期六 + - 星期天 + formats: + long: '%B %-d, %Y' + short: '%A, %B %-d' + month_names: + - + - 1月 + - 2月 + - 3月 + - 4月 + - 5月 + - 6月 + - 7月 + - 8月 + - 9月 + - 10月 + - 11月 + - 12月 + range: '从 %{from} 到 %{to}' + time: + am: 上午 + formats: + event_date: '%B %-d, %Y' + event_time: '%-l:%M %p' + event_timestamp: '%B %-d, %Y at %-l:%M %p' + event_timestamp_js: '%{year} %{month} %{day}, %{hour}:%{minute} %{day_period}' + event_timestamp_utc: '%B %-d, %Y, %-l:%M %p UTC' + event_timestamp_with_zone: '%B %-d, %Y,%-l:%M %p %Z' + sms_date: '%Y/%m/%d' + pm: 下午 diff --git a/config/locales/titles/zh.yml b/config/locales/titles/zh.yml new file mode 100644 index 00000000000..b524168e886 --- /dev/null +++ b/config/locales/titles/zh.yml @@ -0,0 +1,83 @@ +--- +zh: + titles: + account: 账户 + account_locked: 账户临时锁住 + add_info: + phone: 添加电话号码 + backup_codes: 别丢了你的备用代码 + confirmations: + delete: 请确认 + show: 选择一个密码 + doc_auth: + address: 更新你的邮政地址 + doc_capture: 添加你的身份证件 + hybrid_handoff: 验证你的身份证件 + link_sent: 链接已发送 + processing_images: 正在处理你的图像 + ssn: 输入你的社会保障号码 + switch_back: 切换回你的电脑 + verify: 验证你的身份 + edit_info: + email_language: 编辑你电邮语言偏好 + password: 编辑密码 + phone: 编辑你的电话号码 + enter_2fa_code: + one_time_code: 输入安全的一次性代码 + security_code: 输入安全的一次性安全代码 + failure: + information_not_verified: 个人信息未验证 + phone_verification: 电话号码未验证 + forget_all_browsers: 忘掉所有浏览器 + idv: + canceled: Identity verification is canceled + cancellation_prompt: 取消身份验证 + come_back_soon: 稍后再回来 + enter_one_time_code: 输入一次性代码 + enter_password: 重新输入你的密码 + enter_password_letter: 重新输入你的密码来给你发信 + get_letter: 收信 + personal_key: 保存你的个人密钥 + phone: 验证你的电话号码 + reset_password: 重设密码 + verify_info: 验证你的信息 + mfa_setup: + face_touch_unlock_confirmation: 人脸或触摸解锁已添加 + suggest_second_mfa: 你已添加了第一个身份证实方法!添加第二个做备份 + no_auth_option: 没找到登录方法 + openid_connect: + authorization: OpenID Connect 授权 + logout: OpenID Connect 登出 + passwords: + change: 更改你账户密码 + forgot: 重设密码 + personal_key: 万一 + piv_cac_login: + add: 添加你的 PIV 或者 CAC + new: 用 PIV/CAC 登入你的账户 + piv_cac_setup: + new: 用 PIV/CAC 卡来保护你账户安全 + upsell: Enhance your account security with a government employee ID + present_piv_cac: 提供你的 PIV/CAC + present_webauthn: 连接你的硬件安全密钥 + reactivate_account: 重新激活你账户 + registrations: + new: 设立账户 + revoke_consent: 撤销同意 + rules_of_use: 使用规则 + sign_up: + completion_consent_expired_ial1: 从你上次授权我们分享你的信息已经一年了。 + completion_consent_expired_ial2: 从你上次授权我们分享你验证过的身份已经一年了。 + completion_first_sign_in: 继续到 %{sp} + completion_ial2: 连接你验证过的信息到 %{sp} + completion_new_attributes: '%{sp} 在要求新信息' + completion_new_sp: 你现在正在首次登入 + completion_reverified_consent: 与 %{sp}分享你更新后的信息 + confirmation: 继续登录 + totp_setup: + new: 添加身份证实应用程序 + two_factor_setup: 设立双因素身份证实 + verify_email: 检查你的电邮 + visitors: + index: 欢迎。 + webauthn_setup: 添加你的安全密钥 diff --git a/config/locales/two_factor_authentication/zh.yml b/config/locales/two_factor_authentication/zh.yml new file mode 100644 index 00000000000..653dd0b5d26 --- /dev/null +++ b/config/locales/two_factor_authentication/zh.yml @@ -0,0 +1,183 @@ +--- +zh: + two_factor_authentication: + aal2_request: + phishing_resistant_html: '%{sp_name} 要求一种高安全水平的身份证实方法。比如人脸或触摸解锁、安全密钥或政府雇员身份证件。' + piv_cac_only_html: '%{sp_name} 要求你的政府雇员身份证件,这是一种高安全水平的身份证实方法。' + account_reset: + cancel_link: 取消你的请求 + link: 删除你的账户 + pending: 你目前有个待处理的删除账户请求。从你提出请求到完成该流程需要 24 个小时请稍后回来查看。 + successful_cancel: 谢谢。你删除自己 %{app_name} 账户的请求已被取消。 + text_html: 如果你无法使用上述身份证实方法,可以通过 %{link_html} 重设你的首选。 + attempt_remaining_warning_html: + one: 你还可以再试 %{count} 次 。 + other: 你还可以再试 %{count} 次 。 + auth_app: + change_nickname: Change nickname + delete: Delete this device + deleted: Successfully deleted an authentication app method + edit_heading: Manage your authentication app settings + manage_accessible_label: Manage authentication app + nickname: Nickname + renamed: Successfully renamed your authentication app method + backup_code_header_text: 输入你的备用代码。 + backup_code_prompt: 该安全代码可使用一次。提交后,你下次就需要使用一个新备用代码。 + backup_codes: + instructions: 如果你无法使用另一个设备,请将安全代码存放好。万一丢失备用代码,你就无法登录%{app_name}。 + warning_html: 你只在自己账户上设置了备用代码。如果你可以使用另一个设备,比如手机,请用另一种身份证实方法来保护你的账户。 + choose_another_option: '‹ 选择另一个身份证实方法' + form_legend: 选择你的身份证实方法 + header_text: 输入一次性代码 + important_alert_icon: 重要警告标志 + invalid_backup_code: 该备用代码有误 + invalid_otp: 那个一次性代码有误。再试一次或请求新代码。 + invalid_personal_key: 那个个人密钥有误 + invalid_piv_cac: 那个 PIV/CAC 没起作用。确保 PIV/CAC + 是这个账户的。如果的确是,那你的PIV/CAC、个人识别号码(PIN)有问题,或者我们这边出了问题。再试一次或选择另一个身份证实方法。 + learn_more: 了解身份证实选项的更多信息 + login_intro: 你设立账户时设了它们。 + login_intro_reauthentication: 在你能对账户做出任何更改前,我们需要你使用你一种身份证实方法来确定的确是你。 + login_options: + auth_app: 身份证实 app + auth_app_info: 使用你的身份证实应用程序获得一个安全代码。 + backup_code: 备用代码 + backup_code_info: 用你备用代码清单中的一个备用代码来登录。 + personal_key: 个人密钥 + personal_key_info: 使用你设立账户时得到的 16 字符的个人密钥。 + piv_cac: 政府雇员身份证件 + piv_cac_info: 用你的 PIV/CAC 卡而不是安全代码。 + sms: 短信 + sms_info_html: 通过发给 %{phone} 短信得到一次性代码。 + voice: 自动拨打的电话 + voice_info_html: 通过电话%{phone}(仅限北美电话号码)得到一次性代码。 + webauthn: 安全密钥 + webauthn_info: 使用你的安全密钥来访问账户 + webauthn_platform: 人脸或触摸解锁 + webauthn_platform_info: 不用一次性代码,而是用你的面孔或指纹来访问你的账户。 + login_options_link_text: 选择另一个身份证实方法 + login_options_reauthentication_title: 须重新进行身份证实 + login_options_title: 选择你的身份证实方法 + max_backup_code_login_attempts_reached: 为了你的安全,你的账户暂时被锁住,因为你错误输入备用代码太多次。 + max_generic_login_attempts_reached: 为了你的安全,你的账户暂时被锁住。 + max_otp_login_attempts_reached: 为了你的安全,你的账户暂时被锁住,因为你错误输入一次性代码太多次。 + max_otp_requests_reached: 为了你的安全,你的账户暂时被锁住,因为你要求一次性代码太多次。 + max_personal_key_login_attempts_reached: 为了你的安全,你的账户暂时被锁住,因为你错误输入个人密钥太多次。 + max_piv_cac_login_attempts_reached: 为了你的安全,你的账户暂时被锁住,因为你错误提供 piv/cac 凭据太多次。 + mobile_terms_of_service: 移动服务条款 + no_auth_option: 找不到身份证实选项让你登录。 + opt_in: + error_retry: 抱歉,我们让你加入有困难。请再试一次。 + opted_out_html: 你选择不在 %{phone_number_html} 接受短信。你可以选择加入并再在那个电话号码接受安全代码。 + opted_out_last_30d_html: 你选择过去 30 天里不在 %{phone_number_html} 接受短信。我们每 30 天只能允许加入一次电话号码。 + title: 我们无法向你电话号码发送安全代码。 + wait_30d_opt_in: 30 天后,你可以选择加入并在那个电话号码接受安全代码。 + otp_delivery_preference: + instruction: 你可以随时对此进行更改。如果你使用座机电话,请选择“接听电话”。 + landline_warning_html: 输入的电话号码似乎是一个 座机电话。通过 %{phone_setup_path} 请求一次性代码。 + no_supported_options: 我们无法验证 %{location} 的电话号码。 + phone_call: 电话 + sms: 短信(SMS) + sms_unsupported: 我们无法向 %{location} 的电话号码发送短信。 + title: 你会怎么得到代码 + voice: 电话 + voice_unsupported: 我们无法验证给 %{location} 的电话号码打电话。 + otp_make_default_number: + instruction: 把一次性代码发给这一默认号码 + label: 默认电话号码 + one_number_instruction: 你必须添加了不止一个电话号码才能选择一个默认号码。 + one_number_title: 这是你的默认号码 + title: 把这个设为你的默认电话号码? + personal_key_header_text: 输入你的个人密钥 + personal_key_prompt: 该个人密钥可使用一次。输入后,一个新钥会提供给你。 + phone: + delete: + failure: 无法去掉你的电话。 + success: 你的电话已被去掉。 + phone_fee_disclosure: 可能会收短信和数据费。 + phone_info: 你每次登录我们会给你发个一次性代码。 + phone_label: 电话号码 + phone_verification: + troubleshooting: + change_number: 使用另一个电话号码 + code_not_received: 我没收到一次性代码 + piv_cac: + change_nickname: Change nickname + delete: Delete this method + deleted: Successfully deleted a PIV/CAC method + edit_heading: Manage your PIV/CAC settings + manage_accessible_label: Manage PIV/CAC + nickname: Nickname + renamed: Successfully renamed your PIV/CAC method + piv_cac_header_text: 提供你的 PIV/CAC + piv_cac_upsell: + add_piv: Add PIV/CAC card + choose_other_method: Choose other methods instead + existing_user_info: Because you are using a %{email_type} email, we recommend + you add your government employee ID as one of your authentication + methods. This will greatly enhance your account security. + new_user_info: Because you are using a %{email_type} email, we recommend you add + your government employee ID as one of your authentication methods. This + adds another layer of security to your account. + skip: Skip + please_try_again_html: 请在 %{countdown} 后再试一次。 + read_about_two_factor_authentication: 阅读有关双因素身份证实的内容 + recaptcha: + disclosure_statement_html: 该网站由 reCAPTCHA 保护而且谷歌的 %{google_policy_link_html} 和 + %{google_tos_link_html} 都适用。阅读 %{app_name}的 %{login_tos_link_html}。 + google_policy_link: 隐私政策 + google_tos_link: 服务条款 + login_tos_link: 移动使用条款 + recommended: Recommended + totp_header_text: 输入你的身份证实应用程序代码 + two_factor_aal3_choice: 要求额外的身份证实 + two_factor_aal3_choice_intro: 该应用程序要求更高的安全级别。要访问你信息,你需要使用一个实体设备 - 比如安全密钥或政府雇员身份证件(PIV 或 CAC) - 来验证你的身份。 + two_factor_choice: 身份证实方法设置 + two_factor_choice_options: + auth_app: 身份证实应用程序 + auth_app_info: 下载或使用你选择的身份证实应用程序来生成安全代码。 + backup_code: 备用代码 + backup_code_info: 你可以打印或存到你设备里一套 10 个代码。你使用最后一个代码时,我们会生成一套新的。谨记备用代码容易丢失 + configurations_added: + one: '%{count} 已添加' + other: '%{count} 已添加' + no_count_configuration_added: 添加了 + phone: 短信或语音 + phone_info: 通过(SMS)短信或接听电话接受安全代码。 + phone_info_no_voip: 请勿使用基于网络的(VOIP)电话服务或高价(收费)电话号码。 + piv_cac: 政府雇员身份证件 + piv_cac_info: 政府和军队雇员的 PIV/CAC 卡仅限桌面电脑。 + webauthn: 安全密钥 + webauthn_info: 一个实体设备,通常形状像一个U盘,可以插入你的设备。 + webauthn_platform: 人脸或触摸解锁 + webauthn_platform_info: 不用一次性代码,而是用你的面孔或指纹来访问你的账户。 + two_factor_hspd12_choice: 要求额外的身份证实 + two_factor_hspd12_choice_intro: 该应用程序要求更高的安全级别。要访问你的信息,你需要使用政府雇员身份证件 (PIV/CAC) 来验证身份。 + webauthn_authenticating: 正在证实你的凭据… + webauthn_error: + additional_methods_link: 选择另一个身份证实方法 + connect_html: 我们无法连接安全密钥。请再试一次或者 %{link_html}。 + screen_lock_no_other_mfa: 我们无法用人脸或触摸解锁进行身份证实。请试着在你首次设置人脸或触摸解锁的设备上登录。 + screen_lock_other_mfa_html: 我们无法用人脸或触摸解锁进行身份证实。%{link_html},请试着在你首次设置人脸或触摸解锁的设备上登录。 + try_again: 人脸或触摸解锁不成功。请再试一次或者 %{link}。 + use_a_different_method: 使用另一种身份证实方法 + webauthn_header_text: 连接你的安全密钥 + webauthn_platform: + change_nickname: 更改昵称 + delete: 删除该设备 + deleted: 成功地删除了人脸或触摸解锁方法 + edit_heading: 管理你的人脸或触摸解锁设置 + manage_accessible_label: 管理人脸或触摸解锁 + nickname: 昵称 + renamed: 成功地重新命名了你的人脸或触摸解锁方法 + webauthn_platform_header_text: 使用人脸或触摸解锁 + webauthn_platform_use_key: 使用屏幕解锁 + webauthn_roaming: + change_nickname: Change nickname + delete: Delete this device + deleted: Successfully deleted a security key method + edit_heading: Manage your security key settings + manage_accessible_label: Manage security key + nickname: Nickname + renamed: Successfully renamed your security key method + webauthn_use_key: 使用安全密钥 diff --git a/config/locales/user_authorization_confirmation/zh.yml b/config/locales/user_authorization_confirmation/zh.yml new file mode 100644 index 00000000000..9cf86f7bb3f --- /dev/null +++ b/config/locales/user_authorization_confirmation/zh.yml @@ -0,0 +1,7 @@ +--- +zh: + user_authorization_confirmation: + continue: 继续 + currently_logged_in: '你已用以下电邮登入:' + or: 或者 + sign_in: 换电邮 diff --git a/config/locales/user_mailer/zh.yml b/config/locales/user_mailer/zh.yml new file mode 100644 index 00000000000..593d83dc496 --- /dev/null +++ b/config/locales/user_mailer/zh.yml @@ -0,0 +1,263 @@ +--- +zh: + user_mailer: + account_reinstated: + subject: 你的账户已解锁 + we_have_finished_reviewing: 我们已完成对你%{app_name}账户的审查,你现在能使用账户信息登录了。 + account_rejected: + intro: 我们无法在%{app_name}验证你的身份。请联系你试图访问其服务的那个机构。 + subject: 我们无法验证你的身份。 + account_reset_cancel: + intro_html: 这封电邮确认你已取消删除你 %{app_name_html} 账户的请求。 + subject: 请求已取消 + account_reset_complete: + intro_html: 这封电邮确认你已删除你的 %{app_name_html} 账户。 + subject: 账户已删除 + account_reset_granted: + button: 是的,继续删除 + cancel_link_text: 请取消 + help_html: 如果你不想删除你的账户,%{cancel_account_reset_html}。 + intro_html: + 你的24小时等待期已结束。请完成流程第 2 步。

如果你无法找到自己的身份证实方法,选择“确认删除”来删除你的 + %{app_name} 账户。

账户删除后,将来如果你需要访问使用 + %{app_name}的参与这个项目的政府网站,可以使用同一电邮地址来设立一个新 %{app_name} 账户。

+ subject: 删除你的 %{app_name} 账户 + account_reset_request: + cancel: 不想删除你的账户?登入你的 %{app_name} 账户来取消。 + header: 你的账户会在 24 小时后删除。 + intro_html: '作为安全措施, %{app_name} 要求一个两步流程来删除账户:

+ 第一步:如果你丢失了身份证实方法但需删除账户,有一个 24 小时的等待期。如果你找到了身份证实方法, 可登入你的 %{app_name} + 账户来取消这一请求。

第二步:24 小时等待期过了之后,你会收到一封电邮,请你确认删除 %{app_name} + 账户。只有经你确认后,你的账户才会被删除。' + subject: 如何删除你的 %{app_name} 账户 + account_verified: + change_password_link: 更改密码 + contact_link: 联系我们 + intro_html: + 你于 %{date} 使用 %{app_name} 在 %{sp_name}}成功验证了身份。如果你没有采取这一行动,请 + %{contact_link_html} 并登录 %{change_password_link_html}。 + subject: 你在 %{sp_name} 验证了身份。 + add_email: + footer: 这个链接会在 %{confirmation_period} 后作废。 + header: 感谢添加电邮。请点击下面的链接或把整个链接复制并黏贴进浏览器。 + subject: 确认你的电邮 + add_email_associated_with_another_account: + help_html: + 如果你没有要求一封新电邮或怀疑有错, 请访问 %{app_name_html}的 %{help_link_html} 或者 + %{contact_link_html}。 + intro_html: 该电邮地址已与一个 + %{app_name_html}账户相关联,所以我们不能把它加到另外一个账户上。你必须首先将其从与之相关的账户中删除或去掉。要做到这一点,点击以下链接并用该电邮地址登录。如果你没有试图将此电邮地址加到一个账户,可忽略这一信息。 + link_text: 请到 %{app_name} + reset_password_html: 如果你不记得密码,请到 %{app_name_html} 去重设密码。 + contact_link_text: 联系我们 + email_added: + header: 一个新电邮地址加进了你的 %{app_name} 用户资料中。 + help: 如果你没有作此更改,请登录你的用户资料来管理电邮地址。我们推荐你也更改密码。 + subject: 新电邮地址已添加 + email_confirmation_instructions: + first_sentence: + confirmed: 试图更改你电邮地址? + unconfirmed: 感谢提交你的电邮地址。 + footer: 这一链接会在 %{confirmation_period}后作废。 + header: '%{intro}请点击下面的链接或把整个链接复制并黏贴进浏览器。' + link_text: 确认电邮地址 + subject: 确认你的电邮 + email_deleted: + header: 一个电邮地址被从你的 %{app_name} 用户资料中删除。 + help_html: 如果你没有想删除这一电邮地址, 请访问 %{app_name_html} %{help_link_html} 或者 + %{contact_link_html}。 + subject: 电邮地址已删除 + help_link_text: 帮助中心 + in_person_completion_survey: + body: + cta: + callout: 点击下面的按钮来开始 + label: 填写我们的意见调查 + greeting: 你好, + intent: 我们想听听你在邮局亲身验证身份的经历。 + privacy_html: 你对这份意见调查的回复将会依照下面的 隐私和安全标准 得到保护。 + request_description: 填写一个简短、匿名的调查问卷,你的意见会帮助我们更好满足你的需求。 + thanks: 感谢使用 %{app_name}。 + header: 花点时间告诉我们,我们表现如何 + subject: 讲讲你最近在 %{app_name} 的经历 + in_person_deadline_passed: + body: + canceled: 亲身验证身份的截止日期已过,为了保护你的个人数据,我们自动取消了你的请求。 + cta: 重新开始 + greeting: 你好, + restart: 你可以开始提出在 %{partner_agency}验证身份的新请求。 + header: 亲身验证身份的截止日期已过。 + subject: 你亲身验证身份的要求已过期。 + in_person_failed: + body: + with_cta: 点击按钮或者复制下面的链接来再次在网上通过 %{sp_or_app_name}验证你的身份。如果你仍然遇到问题,请联系你要访问的政府机构。 + without_cta: 请尝试从 %{sp_name}的网站再次验证你的身份。如果你仍然遇到问题,请联系你要访问的政府机构。 + intro: 你的身份于 %{date}在 %{location} 邮局未能得到验证。 + subject: 你的身份未能亲身被验证。 + verifying_identity: '验证你的身份时:' + verifying_step_not_expired: 你的州政府颁发的身份证件或驾照绝对没有过期。我们目前不接受任何其他形式的身份证件,比如护照和军队身份证件。 + in_person_failed_suspected_fraud: + body: + help_center_html: 如需要更多帮助,可以访问我们的帮助中心 或寻求你试图访问的机构的帮助。 + intro: 我们知道你曾经试图通过 %{app_name} 验证身份,但是你的身份于 %{date}在 %{location} 邮局未能得到验证。 + greeting: 你好, + subject: 你的身份未能亲身被验证。 + in_person_outage_notification: + body: + closing_html: 对于服务的延迟,我们诚恳道歉。如果你 6 月 1 日之前有问题或没有收到结果,请 联系 %{app_name} 客户支持。 + heading: 亲身验证身份的结果从 5 月 20 日延到了 5 月 30 日。 + instructions: 如果你已尝试过在邮局完成验证身份的过程,现在则没必要再去试。请放心,你的验证结果没有丢失,而是已被安全保存,而且等我们解决技术问题后就会通过电邮发给你。 + intro_html: + 我们正在解决导致亲身验证身份结果延迟的一个技术问题。如果你 5 月 20 日到 29 + 日之间已经尝试过在邮局完成验证身份的过程,结果可能直到 5 月 30 日(礼拜二)才能通过电邮发给你 。 + subject: '%{app_name} 亲身验证身份的结果延迟了' + in_person_please_call: + body: + contact_message_html: Call %{contact_number} and provide them + with the error code %{support_code}. + intro_html: Call our contact center by %{date} to continue + verifying your identity. + header: Please give us a call + subject: Call %{app_name} to continue with your identity verification + in_person_ready_to_verify: + subject: 你可以在 %{app_name} 亲身验证身份了 + in_person_ready_to_verify_reminder: + greeting: 你好, + heading: + one: 你距离亲身验证身份截止日期还有 %{count} 天 + other: 你距离亲身验证身份截止日期还有 %{count} 天 + intro: 不要错过到你附近邮局验证身份的机会。完成这一步以访问 %{sp_name}。 + subject: + one: 第二天去邮局验证身份。 + other: 过%{count} 天去邮局验证身份。 + in_person_verified: + greeting: 你好, + intro: 你于 %{date}在 %{location} 邮局成功地验证了身份。 + next_sign_in: + with_sp: + with_cta: 接下来请点击按钮或复制下面的连接来访问 %{sp_name} 并登录。 + without_cta: 你现在可以从 %{sp_name} 的网站登录。 + without_sp: 接下来请点击按钮或复制下面的连接来登录 %{sp_name}。 + sign_in: 登录 + subject: 你在 %{sp_name} 成功地验证了身份 + warning_contact_us_html: 如果你没有试图亲身验证身份,请登入 + 重设密码。要报告这件事,联系 %{app_name} 支持 + %{app_name}。 + letter_reminder: + info_html: 你将收到的信件会含有帮助我们验证你地址的一次性代码。你可以登入 %{link_html} 并输入该一次性代码来完成身份验证流i程。 + subject: 我们已向你存档地址发送了一封信。 + letter_reminder_14_days: + body_html:

%{date_letter_was_sent} + 日你要求了带有验证码的信

登录%{app_name} 并输入验证码来完成验证你的身份。 + %{help_link}.

+ did_not_get_a_letter_html: 如果你没有收到这封信, %{another_letter_link_html}。 + finish: 完成验证你的身份 + sign_in_and_request_another_letter: 登录要求再发一封信 + subject: 完成验证你的身份 + new_device_sign_in: + disavowal_link: 重设你的密码 + help_html: 如果你没做此更改, %{disavowal_link_html}。要得到更多帮助,请访问 %{app_name_html} + %{help_link_html} 或者 %{contact_link_html}。 + info: '' + subject: 用你 %{app_name} 账户进行的新登录 + new_device_sign_in_after_2fa: + authentication_methods: authentication methods + info_p1: Your %{app_name} email and password were used to sign-in and + authenticate on a new device. + info_p2: If you recognize this activity, you don’t need to do anything. + info_p3_html: If this wasn’t you, %{reset_password_link_html} and change your + %{authentication_methods_link_html} immediately. + reset_password: reset your password + subject: New sign-in and authentication with your %{app_name} account + new_device_sign_in_attempts: + events: + sign_in_after_2fa: Authenticated + sign_in_before_2fa: Signed in with password + sign_in_unsuccessful_2fa: Failed to authenticate + new_sign_in_from: New sign-in potentially located in %{location} + new_device_sign_in_before_2fa: + info_p1_html: + one: Your %{app_name} email and password were used to sign in from a new device + but failed to authenticate. + other: Your %{app_name} email and password were used to sign in from a new + device but failed to authenticate %{count} times. + zero: Your %{app_name} email and password were used to sign in from a new device + but failed to authenticate. + info_p2: If you recognize this activity, you don’t need to do anything. + info_p3_html: Two-factor authentication protects your account from unauthorized + access. If this wasn’t you, %{reset_password_link_html} immediately. + reset_password: reset your password + subject: New sign-in with your %{app_name} account + password_changed: + disavowal_link: 重设你的密码 + help_html: 如果你没做此更改, %{disavowal_link_html}。要得到更多帮助,请访问 %{app_name_html} + %{help_link_html} 或者 %{contact_link_html}。 + intro_html: 你的 %{app_name_html} 账户有了新密码。 + personal_key_regenerated: + help_html:

你的 %{app_name} 账户刚得到了一个新的 16 + 字符的个人密钥。你收到这一电邮是为了确保就是你。

如果你刚刚登入并重设了个人密钥,没问题!你无需做任何事情。

如果你刚才没有重设个人密钥,或者你不确定,请立即采取以下步骤来保护你账户安全:

  1. 更改你的密码 + 选择一个你在该账户没用过的密码。
  2. 登录你的 + %{app_name} 账户 + 并确保你账户页面上的信息你都能认出,包括你进行双因素身份证实的方法,比如电话号码、身份证实 app + 或安全密钥。
  3. 在你的%{app_name} 账户页面, + 请求新个人密钥。 请记住,除非你在用该密钥登录一个使用 %{app_name} + 的受到信任的网站,绝对不要将其与人分享。


谢谢,
%{app_name} 团队 + intro: 新个人密钥已发放 + subject: 账户安全警告 + personal_key_sign_in: + help_html:

你的 16 字符的个人密钥刚被用来登录你的 %{app_name} + 账户。你收到这一电邮是为了确保就是你。

如果你刚用自己的个人密钥登入,没问题!你无需做任何事情。

如果你没用个人密钥登录,或者你不确定,请立即采取以下步骤来保护你账户安全:

  1. 更改你的密码 + 选择一个你在该账户没用过的密码。
  2. 登录你的 + %{app_name} 账户 + 并确保你账户页面上的信息你都能认出,包括你进行双因素身份证实的方法,比如电话号码、身份证实 app + 或者安全密钥。
  3. 在你的%{app_name} + 账户页面, 请求新个人密钥。 + 请记住,除非你在用密钥登入一个使用%{app_name}的受到信任的网站,绝对不要将其与人分享。


谢谢,
%{app_name} + 团队 + intro: 个人密钥被用来登录 + subject: 账户安全警告 + phone_added: + disavowal_link: 重设你的密码 + help_html: 如果你没做此更改,请登录进入你的用户资料并管理电邮地址。我们还建议你 %{disavowal_link_html}。 + intro: 你的 %{app_name} 用户资料添加了一个新电话号码。 + subject: 新电话号码已添加。 + please_reset_password: + call_to_action: '为了谨慎起见,我们已禁用你的密码以保护你信息的安全。请按照以下步骤重设你的密码和保护你账户安全:' + intro: 我们在你的 %{app_name} 账户上探查到了异常活动。我们担心别人(不是你)在试图访问你的信息。 + learn_more_link_text: 了解更多有关你选项的信息。 + reminder_html: 提醒一下, %{app_name} + 永远不会通过电话或电邮索要你的登录凭证。你可以通过启动双因素身份证实,采取额外步骤来保护你账户安全。 + step_1: 访问 %{app_name} 网站并选择登录 + step_2: 选择页面底部的“忘了你的密码?” + step_3: 按照说明重设你的密码 + subject: 异常活动 — 重设你的 %{app_name} 密码 + reset_password_instructions: + footer: 这一链接 %{expires} 小时后会作废。 + gpo_letter_description: 如果你重设密码,信件中的一次性代码就会失效,你需要再次验证身份。 + gpo_letter_header: 你的信件已寄出。 + header: 要完成重设密码,请点击下面的链接或把整个链接复制并黏贴进浏览器。 + link_text: 重设你的密码 + subject: 重设你的密码 + signup_with_your_email: + help_html: + 如果你没有要求一封新电邮或怀疑有错, 请访问 %{app_name_html}的 %{help_link_html} 或者 + %{contact_link_html}。 + intro_html: + 该电邮地址已与一个 %{app_name_html}账户相关联,所以我们不能用它设立新账户。要使用你现有账户登录,请点击以下链接。 + 如果你未试图用此电邮地址登录,可以忽略这一信息。 + link_text: 请到 %{app_name} + reset_password_html: 如果你不记得密码,请到 %{app_name_html} 去重设密码。 + suspended_create_account: + message: 用该电邮地址创建你的%{app_name} 账户出现了问题。请拨打 + %{contact_number}到我们联系中心并提供这一代码%{support_code}。 + subject: 我们无法创建你的账户。 + suspended_reset_password: + message: 重设你的密码出了问题。请拨打 %{contact_number}到我们联系中心并提供这一代码%{support_code}。 + subject: 我们无法重设你的密码。 + suspension_confirmed: + contact_agency: 请联系你试图访问其服务的那个机构。 + remain_locked: 我们已完成了对你%{app_name}账户的审查,你的账户将继续被锁。 + subject: 你的账户被锁 diff --git a/config/locales/users/zh.yml b/config/locales/users/zh.yml new file mode 100644 index 00000000000..6cfde61f95c --- /dev/null +++ b/config/locales/users/zh.yml @@ -0,0 +1,43 @@ +--- +zh: + users: + delete: + actions: + cancel: 返回用户资料 + delete: 删除账户 + bullet_1: 你不会有 %{app_name} 账户 + bullet_2_basic: 我们将删除你的电邮地址、密码和电话号码。 + bullet_2_verified: '%{app_name} 将把你的电邮地址、密码、 电话号码、姓名、地址、生日以及社会保障号码从我们系统中删除。' + bullet_3: 你将无法使用 %{app_name} 来安全访问你的信息。 + bullet_4: 我们将通知你使用 %{app_name} 访问的政府机构你不再有账户。 + heading: 你确定要删除账户吗? + instructions: 输入密码来确认你要删除账户。 + subheading: '如果你删除自己的账户:' + personal_key: + accessible_labels: + code_example: 有 16 个字符的个人密钥示例 + preview: 个人密钥预览 + confirmation_error: 你输入的个人密钥不对 + generated_on_html: 你的个人密钥在 %{date_html} 生成。 + phones: + error_message: 你添加的电话号码已达数量上限。 + rules_of_use: + check_box_to_accept: 在该方框里打勾来接受 %{app_name} + details_html: |- +
使用规则:
+
    +
  • 解释 %{app_name} 服务原理以及你应有的期望,
  • +
  • 我们给你提供 %{app_name} 服务的服务条款,
  • +
  • 我们如何使用你的信息以及你对此信息拥有的权力,以及 and
  • +
  • 你同意的在 %{app_name} 采取某些行动的前提条件。
  • +
+ overview_html: 我们已更新了我们的 %{link_html}。请审阅并在下面方框里打勾来继续。 + second_mfa_reminder: + add_method: 添加另外一个身份证实方法 + continue: 继续到 %{sp_name} + description: 你的账户只有一个身份证实方法。添加另外一个身份证实方法来避免被锁在账户外。 + heading: 改善你账户安全 + suspended_sign_in_account: + contact_details: 我们无法把你登录进去。请给我们联系中心打电话,号码是 %{contact_number}。 + error_details: 请提供错误代码 %{error_code}。 + heading: 请给我们打个电话 diff --git a/config/locales/valid_email/zh.yml b/config/locales/valid_email/zh.yml new file mode 100644 index 00000000000..671b3695d71 --- /dev/null +++ b/config/locales/valid_email/zh.yml @@ -0,0 +1,6 @@ +--- +zh: + valid_email: + validations: + email: + invalid: 电邮地址无效 diff --git a/config/locales/vendor_outage/zh.yml b/config/locales/vendor_outage/zh.yml new file mode 100644 index 00000000000..99949dbbbb3 --- /dev/null +++ b/config/locales/vendor_outage/zh.yml @@ -0,0 +1,31 @@ +--- +zh: + vendor_outage: + alerts: + phone: + default: 我们目前无法验证电话。请使用另一个身份证实方法(如果有),或者稍后再试。 + idv: 我们目前无法验证电话。请等一会再试或使用邮件来验证地址。 + pinpoint: + idv: + header: 我们正在争取解决错误。 + message_html: '%{sp_name_html}需要确保你是你,而不是别人冒充你。' + options_html: + - 现在继续并通过普通邮件验证,这需要5 到 10 天。 + - 退出 Login.gov,稍后再试。 + options_prompt: '你可以:' + status_page_html: 遗憾的是,我们目前遇到技术困难。到 %{link_html} 了解错误何时能解决。 + status_page_link: 在我们的状态页面获得最新信息。 + sms: + default: 我们目前无法发送短信(SMS)。你可以通过接听电话得到一个代码,或选择另一个身份证实方法(如果有)。 + idv: 我们目前无法发送短信(SMS)。你可以通过接听电话得到一个代码,或者使用邮件来验证地址。 + voice: + default: 我们目前无法拨打电话。你可以通过短信(SMS)得到一个代码,或选择另一个身份证实方法(如果有)。 + idv: 我们目前无法拨打电话。你可以通过短信(SMS)得到一个代码,或者使用邮件来验证地址。 + blocked: + idv: + generic: 我们这边现在遇到技术困难,目前无法验证你的身份。请稍后再试。 + phone: + default: 我们目前无法验证电话。请稍后再试。 + get_updates: 获得最新信息 + get_updates_on_status_page: 在我们的状态页面获得最新信息。 + working: 我们正在争取解决错误。 diff --git a/config/locales/zxcvbn/zh.yml b/config/locales/zxcvbn/zh.yml new file mode 100644 index 00000000000..550870e22dc --- /dev/null +++ b/config/locales/zxcvbn/zh.yml @@ -0,0 +1,33 @@ +--- +zh: + zxcvbn: + feedback: + a_word_by_itself_is_easy_to_guess: 单字容易被人猜出 + add_another_word_or_two_uncommon_words_are_better: 再加一两个字不常见的字更好 + all_uppercase_is_almost_as_easy_to_guess_as_all_lowercase: 都是大写几乎和都是小写一样容易被人猜出 + avoid_dates_and_years_that_are_associated_with_you: 避免与你相关的日期和年份 + avoid_recent_years: 避免最近几年的年份 + avoid_repeated_words_and_characters: 避免重复字和字符 + avoid_sequences: 避免排序 + avoid_years_that_are_associated_with_you: 避免与你相关的年份 + capitalization_doesnt_help_very_much: 把字母大写并无多大帮助 + common_names_and_surnames_are_easy_to_guess: 常见姓名容易被猜出 + dates_are_often_easy_to_guess: 日期常常容易被猜出 + for_a_stronger_password_use_a_few_words_separated_by_spaces_but_avoid_common_phrases: 强密码请使用被空格分开的几个字,但避免常见短语 + names_and_surnames_by_themselves_are_easy_to_guess: 名字和姓氏本身容易被猜出 + no_need_for_symbols_digits_or_uppercase_letters: 不需要符号、数字或大写字母 + predictable_substitutions_like__instead_of_a_dont_help_very_much: 用‘@’ 代替‘a’这样的可预测替换,并无多大帮助 + recent_years_are_easy_to_guess: 近年的年份容易被猜出 + repeats_like_aaa_are_easy_to_guess: “aaa”这样的重复容易被猜出 + repeats_like_abcabcabc_are_only_slightly_harder_to_guess_than_abc: “abcabcabc”这样的重复与“abc”这样的重复相比,只是稍微难猜一点 + reversed_words_arent_much_harder_to_guess: 反向拼写的字也不太难猜 + sequences_like_abc_or_6543_are_easy_to_guess: abc 或 6543 这样的排序容易猜 + short_keyboard_patterns_are_easy_to_guess: 在键盘上有规律的简短组合容易猜 + straight_rows_of_keys_are_easy_to_guess: 直接一行键也容易猜 + there_is_no_need_for_symbols_digits_or_uppercase_letters: 没必要使用符号、数字或大写字母 + this_is_a_top_10_common_password: 这是最常见的 10 个密码之一 + this_is_a_top_100_common_password: 这是最常见的 100 个密码之一 + this_is_a_very_common_password: 这是很常见的一个密码 + this_is_similar_to_a_commonly_used_password: 这与常见密码很像 + use_a_few_words_avoid_common_phrases: 使用几个字,但避免常见短语 + use_a_longer_keyboard_pattern_with_more_turns: 使用键盘上长些的曲里拐弯的键组合 diff --git a/lib/telephony/pinpoint/voice_sender.rb b/lib/telephony/pinpoint/voice_sender.rb index 8cefe338429..f35134d0507 100644 --- a/lib/telephony/pinpoint/voice_sender.rb +++ b/lib/telephony/pinpoint/voice_sender.rb @@ -10,6 +10,7 @@ class VoiceSender en: DEFAULT_VOICE_ID, fr: ['fr-FR', 'Mathieu'], es: ['es-US', 'Miguel'], + zh: ['cmn-CN', 'Zhiyu'], }.freeze # One connection pool per config (aka per-region) # rubocop:disable Style/MutableConstant diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index 95bc616813b..b35e176c306 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -8,6 +8,28 @@ 'time.formats.event_timestamp_js', ].sort.freeze +# These are keys with mismatch interpolation for specific locales +ALLOWED_INTERPOLATION_MISMATCH_LOCALE_KEYS = [ + # need to be fixed + 'zh.account_reset.pending.confirm', + 'zh.account_reset.pending.wait_html', + 'zh.account_reset.recovery_options.check_webauthn_platform_info', + 'zh.doc_auth.headings.welcome', + 'zh.doc_auth.info.exit.with_sp', + 'zh.idv.cancel.headings.exit.with_sp', + 'zh.idv.failure.exit.with_sp', + 'zh.in_person_proofing.body.barcode.return_to_partner_link', + 'zh.mfa.info', + 'zh.telephony.account_reset_notice', + 'zh.two_factor_authentication.account_reset.pending', + 'zh.user_mailer.account_reset_granted.intro_html', + 'zh.user_mailer.account_reset_request.header', + 'zh.user_mailer.account_reset_request.intro_html', + 'zh.user_mailer.in_person_verified.next_sign_in.without_sp', + 'zh.user_mailer.in_person_verified.subject', + 'zh.user_mailer.new_device_sign_in.info', +].sort.freeze + # A set of patterns which are expected to only occur within specific locales. This is an imperfect # solution based on current content, intended to help prevent accidents when adding new translated # content. If you are having issues with new content, it would be reasonable to remove or modify @@ -29,12 +51,15 @@ module I18n module Tasks class BaseTask # List of keys allowed to be untranslated or are the same as English + # rubocop:disable Layout/LineLength ALLOWED_UNTRANSLATED_KEYS = [ { key: 'account.navigation.menu', locales: %i[fr] }, # "Menu" is "Menu" in French { key: /^countries/ }, # Some countries have the same name across languages { key: 'datetime.dotiw.minutes.one' }, # "minute is minute" in French and English { key: 'datetime.dotiw.minutes.other' }, # "minute is minute" in French and English - { key: /^i18n\.locale\./ }, # Show locale options translated as that language + { key: 'i18n.locale.en', locales: %i[es fr zh] }, + { key: 'i18n.locale.es', locales: %i[es fr zh] }, + { key: 'i18n.locale.fr', locales: %i[es fr zh] }, { key: 'links.contact', locales: %i[fr] }, # "Contact" is "Contact" in French { key: 'mailer.logo' }, # "logo is logo" in English, French and Spanish { key: 'saml_idp.auth.error.title', locales: %i[es] }, # "Error" is "Error" in Spanish @@ -43,7 +68,166 @@ class BaseTask { key: 'time.formats.sms_date' }, # for us date format { key: 'time.pm' }, # "PM" is "PM" in French and Spanish { key: 'datetime.dotiw.words_connector' }, # " , " is only punctuation and not translated + { key: 'date.formats.long', locales: %i[zh] }, + { key: 'date.formats.short', locales: %i[zh] }, + { key: 'time.formats.event_date', locales: %i[zh] }, + { key: 'time.formats.event_time', locales: %i[zh] }, + { key: 'time.formats.event_timestamp', locales: %i[zh] }, + # need to be fixed + { key: 'i18n.locale.zh', locales: %i[es fr zh] }, + { key: 'account.email_language.name.zh', locales: %i[es fr zh] }, + { key: 'account_reset.pending.canceled', locales: %i[zh] }, + { key: 'account_reset.recovery_options.check_saved_credential', locales: %i[zh] }, + { key: 'account_reset.recovery_options.use_same_device', locales: %i[zh] }, + { key: 'anonymous_mailer.password_reset_missing_user.create_new_account', locales: %i[zh] }, + { key: 'anonymous_mailer.password_reset_missing_user.info_no_account', locales: %i[zh] }, + { key: 'anonymous_mailer.password_reset_missing_user.info_request_different', locales: %i[zh] }, + { key: 'anonymous_mailer.password_reset_missing_user.subject', locales: %i[zh] }, + { key: 'anonymous_mailer.password_reset_missing_user.try_different_email', locales: %i[zh] }, + { key: 'anonymous_mailer.password_reset_missing_user.use_this_email_html', locales: %i[zh] }, + { key: 'doc_auth.buttons.close', locales: %i[zh] }, + { key: 'doc_auth.errors.alerts.selfie_not_live', locales: %i[zh] }, + { key: 'doc_auth.errors.alerts.selfie_not_live_help_link_text', locales: %i[zh] }, + { key: 'doc_auth.errors.alerts.selfie_poor_quality', locales: %i[zh] }, + { key: 'doc_auth.errors.general.selfie_failure', locales: %i[zh] }, + { key: 'doc_auth.errors.general.selfie_failure_help_link_text', locales: %i[zh] }, + { key: 'doc_auth.headings.hybrid_handoff_selfie', locales: %i[zh] }, + { key: 'doc_auth.info.getting_started_html', locales: %i[zh] }, + { key: 'doc_auth.info.getting_started_learn_more', locales: %i[zh] }, + { key: 'doc_auth.info.hybrid_handoff_ipp_html', locales: %i[zh] }, + { key: 'doc_auth.info.selfie_capture_content', locales: %i[zh] }, + { key: 'doc_auth.info.selfie_capture_status.face_close_to_border', locales: %i[zh] }, + { key: 'doc_auth.info.selfie_capture_status.face_not_found', locales: %i[zh] }, + { key: 'doc_auth.info.selfie_capture_status.face_too_small', locales: %i[zh] }, + { key: 'doc_auth.info.selfie_capture_status.too_many_faces', locales: %i[zh] }, + { key: 'doc_auth.info.stepping_up_html', locales: %i[zh] }, + { key: 'doc_auth.instructions.bullet4', locales: %i[zh] }, + { key: 'doc_auth.instructions.getting_started', locales: %i[zh] }, + { key: 'doc_auth.instructions.text3', locales: %i[zh] }, + { key: 'doc_auth.instructions.text4', locales: %i[zh] }, + { key: 'doc_auth.tips.document_capture_selfie_text4', locales: %i[zh] }, + { key: 'errors.doc_auth.document_capture_canceled', locales: %i[zh] }, + { key: 'errors.doc_auth.selfie_fail_heading', locales: %i[zh] }, + { key: 'errors.doc_auth.selfie_not_live_or_poor_quality_heading', locales: %i[zh] }, + { key: 'errors.messages.blank_cert_element_req', locales: %i[zh] }, + { key: 'event_types.sign_in_notification_timeframe_expired', locales: %i[zh] }, + { key: 'event_types.sign_in_unsuccessful_2fa', locales: %i[zh] }, + { key: 'forms.buttons.continue_ipp', locales: %i[zh] }, + { key: 'forms.buttons.continue_remote', locales: %i[zh] }, + { key: 'forms.webauthn_setup.intro', locales: %i[zh] }, + { key: 'forms.webauthn_setup.learn_more', locales: %i[zh] }, + { key: 'forms.webauthn_setup.set_up', locales: %i[zh] }, + { key: 'forms.webauthn_setup.step_1', locales: %i[zh] }, + { key: 'forms.webauthn_setup.step_1a', locales: %i[zh] }, + { key: 'forms.webauthn_setup.step_2', locales: %i[zh] }, + { key: 'forms.webauthn_setup.step_2_image_alt', locales: %i[zh] }, + { key: 'forms.webauthn_setup.step_2_image_mobile_alt', locales: %i[zh] }, + { key: 'forms.webauthn_setup.step_3', locales: %i[zh] }, + { key: 'forms.webauthn_setup.step_3a', locales: %i[zh] }, + { key: 'idv.failure.setup.fail_html', locales: %i[zh] }, + { key: 'idv.failure.verify.exit', locales: %i[zh] }, + { key: 'image_description.phone_icon', locales: %i[zh] }, + { key: 'in_person_proofing.form.state_id.state_id_number_florida_hint_html', locales: %i[zh] }, + { key: 'mfa.recommendation', locales: %i[zh] }, + { key: 'notices.signed_up_but_unconfirmed.resend_confirmation_email', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.no_valid_vtr', locales: %i[zh] }, + { key: 'telephony.account_deleted_notice', locales: %i[zh] }, + { key: 'titles.idv.canceled', locales: %i[zh] }, + { key: 'titles.piv_cac_setup.upsell', locales: %i[zh] }, + { key: 'two_factor_authentication.auth_app.change_nickname', locales: %i[zh] }, + { key: 'two_factor_authentication.auth_app.delete', locales: %i[zh] }, + { key: 'two_factor_authentication.auth_app.deleted', locales: %i[zh] }, + { key: 'two_factor_authentication.auth_app.edit_heading', locales: %i[zh] }, + { key: 'two_factor_authentication.auth_app.manage_accessible_label', locales: %i[zh] }, + { key: 'two_factor_authentication.auth_app.nickname', locales: %i[zh] }, + { key: 'two_factor_authentication.auth_app.renamed', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac.change_nickname', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac.delete', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac.deleted', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac.edit_heading', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac.manage_accessible_label', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac.nickname', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac.renamed', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac_upsell.add_piv', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac_upsell.choose_other_method', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac_upsell.existing_user_info', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac_upsell.new_user_info', locales: %i[zh] }, + { key: 'two_factor_authentication.piv_cac_upsell.skip', locales: %i[zh] }, + { key: 'two_factor_authentication.recommended', locales: %i[zh] }, + { key: 'two_factor_authentication.webauthn_roaming.change_nickname', locales: %i[zh] }, + { key: 'two_factor_authentication.webauthn_roaming.delete', locales: %i[zh] }, + { key: 'two_factor_authentication.webauthn_roaming.deleted', locales: %i[zh] }, + { key: 'two_factor_authentication.webauthn_roaming.edit_heading', locales: %i[zh] }, + { key: 'two_factor_authentication.webauthn_roaming.manage_accessible_label', locales: %i[zh] }, + { key: 'two_factor_authentication.webauthn_roaming.nickname', locales: %i[zh] }, + { key: 'two_factor_authentication.webauthn_roaming.renamed', locales: %i[zh] }, + { key: 'user_mailer.in_person_please_call.body.contact_message_html', locales: %i[zh] }, + { key: 'user_mailer.in_person_please_call.body.intro_html', locales: %i[zh] }, + { key: 'user_mailer.in_person_please_call.header', locales: %i[zh] }, + { key: 'user_mailer.in_person_please_call.subject', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_after_2fa.authentication_methods', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_after_2fa.info_p1', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_after_2fa.info_p2', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_after_2fa.info_p3_html', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_after_2fa.reset_password', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_after_2fa.subject', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_attempts.events.sign_in_after_2fa', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_attempts.events.sign_in_before_2fa', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_attempts.events.sign_in_unsuccessful_2fa', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_attempts.new_sign_in_from', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_before_2fa.info_p1_html.one', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_before_2fa.info_p1_html.other', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_before_2fa.info_p1_html.zero', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_before_2fa.info_p2', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_before_2fa.info_p3_html', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_before_2fa.reset_password', locales: %i[zh] }, + { key: 'user_mailer.new_device_sign_in_before_2fa.subject', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.bad_client_id', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.invalid_verified_within_duration.one', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.invalid_verified_within_duration.other', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.invalid_verified_within_format', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.missing_ial', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.no_auth', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.no_valid_acr_values', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.no_valid_scope', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.prompt_invalid', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.redirect_uri_invalid', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.redirect_uri_no_match', locales: %i[zh] }, + { key: 'openid_connect.authorization.errors.unauthorized_scope', locales: %i[zh] }, + { key: 'openid_connect.logout.confirm', locales: %i[zh] }, + { key: 'openid_connect.logout.deny', locales: %i[zh] }, + { key: 'openid_connect.logout.errors.client_id_invalid', locales: %i[zh] }, + { key: 'openid_connect.logout.errors.client_id_missing', locales: %i[zh] }, + { key: 'openid_connect.logout.errors.id_token_hint', locales: %i[zh] }, + { key: 'openid_connect.logout.errors.id_token_hint_present', locales: %i[zh] }, + { key: 'openid_connect.logout.errors.no_client_id_or_id_token_hint', locales: %i[zh] }, + { key: 'openid_connect.logout.heading', locales: %i[zh] }, + { key: 'openid_connect.logout.heading_with_sp', locales: %i[zh] }, + { key: 'openid_connect.token.errors.expired_code', locales: %i[zh] }, + { key: 'openid_connect.token.errors.invalid_aud', locales: %i[zh] }, + { key: 'openid_connect.token.errors.invalid_authentication', locales: %i[zh] }, + { key: 'openid_connect.token.errors.invalid_code', locales: %i[zh] }, + { key: 'openid_connect.token.errors.invalid_code_verifier', locales: %i[zh] }, + { key: 'openid_connect.token.errors.invalid_iat', locales: %i[zh] }, + { key: 'openid_connect.token.errors.invalid_signature', locales: %i[zh] }, + { key: 'openid_connect.user_info.errors.malformed_authorization', locales: %i[zh] }, + { key: 'openid_connect.user_info.errors.no_authorization', locales: %i[zh] }, + { key: 'openid_connect.user_info.errors.not_found', locales: %i[zh] }, + { key: 'risc.security_event.errors.alg_unsupported', locales: %i[zh] }, + { key: 'risc.security_event.errors.aud_invalid', locales: %i[zh] }, + { key: 'risc.security_event.errors.event_type_missing', locales: %i[zh] }, + { key: 'risc.security_event.errors.event_type_unsupported', locales: %i[zh] }, + { key: 'risc.security_event.errors.exp_present', locales: %i[zh] }, + { key: 'risc.security_event.errors.jti_not_unique', locales: %i[zh] }, + { key: 'risc.security_event.errors.jti_required', locales: %i[zh] }, + { key: 'risc.security_event.errors.jwt_could_not_parse', locales: %i[zh] }, + { key: 'risc.security_event.errors.no_public_key', locales: %i[zh] }, + { key: 'risc.security_event.errors.sub_not_found', locales: %i[zh] }, + { key: 'risc.security_event.errors.sub_unsupported', locales: %i[zh] }, + { key: 'risc.security_event.errors.subject_type_unsupported', locales: %i[zh] }, + { key: 'risc.security_event.errors.typ_error', locales: %i[zh] }, ].freeze + # rubocop:enable Layout/LineLength def untranslated_keys data[base_locale].key_values.each_with_object([]) do |key_value, result| @@ -117,6 +301,7 @@ def allowed_untranslated_key?(locale, key) it 'does not have keys with missing interpolation arguments (check callsites for correct args)' do missing_interpolation_argument_keys = [] + missing_interpolation_argument_locale_keys = [] i18n.data[i18n.base_locale].select_keys do |key, _node| if key.start_with?('i18n.transliterate.rule.') || i18n.t(key).is_a?(Array) || i18n.t(key).nil? @@ -124,6 +309,10 @@ def allowed_untranslated_key?(locale, key) end interpolation_arguments = i18n.locales.map do |locale| + if ALLOWED_INTERPOLATION_MISMATCH_LOCALE_KEYS.include?("#{locale}.#{key}") + missing_interpolation_argument_locale_keys.push("#{locale}.#{key}") + next + end extract_interpolation_arguments i18n.t(key, locale) end.compact @@ -131,6 +320,9 @@ def allowed_untranslated_key?(locale, key) end expect(missing_interpolation_argument_keys.sort).to eq ALLOWED_INTERPOLATION_MISMATCH_KEYS + expect(missing_interpolation_argument_locale_keys.sort).to eq( + ALLOWED_INTERPOLATION_MISMATCH_LOCALE_KEYS, + ) end it 'has matching HTML tags' do From 2c4137333fbe39baa57edf69738ae7ad102fbe82 Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Fri, 26 Apr 2024 11:48:34 -0400 Subject: [PATCH 10/14] LG-13133 | Opt-in IPP dev config change (#10490) * LG-13133 | Opt-in IPP dev config change Enable opt-in IPP in application.yml.default's dev section. This should have no impact in other environments. changelog: Internal, In-Person Proofing, Enabled opt-in IPP in development * enabling opt_it ipp globally to see what breaks * doc_auth_helper now aware of opt in step * extract if statement to new func for portability * address controller spec has opt_in in analytics but seems unaware of value on session * log param for opt in IPP * track opt in on ready to verify page * toggle opt in flag appropriately * Revert "toggle opt in flag appropriately" This reverts commit 29983016d15f229ab2c1f9d22a4a7b9f33926ba0. * Revert "track opt in on ready to verify page" This reverts commit 8b598b7d40724bca98b2d364ad8a6b34fa6188ab. * Revert "log param for opt in IPP" This reverts commit 7c5a6fb1066c45603d885bcba43753cbfe86512f. * Revert "address controller spec has opt_in in analytics but seems unaware of value on session" This reverts commit a9f60197f3650e5539ee520b5698b6ff2af2ce5a. * Revert "extract if statement to new func for portability" This reverts commit dfc5fe7e6badb724929ad0982a118eda748e8029. * Revert "doc_auth_helper now aware of opt in step" This reverts commit 26a078b0d1cb48165f76a2a9cfdfa1306c91bed1. * Revert "enabling opt_it ipp globally to see what breaks" This reverts commit 561f6b514aad074eb4fc9505995c728629443633. --------- Co-authored-by: JackRyan1989 --- config/application.yml.default | 1 + 1 file changed, 1 insertion(+) diff --git a/config/application.yml.default b/config/application.yml.default index fd11a18710c..417085b30f6 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -409,6 +409,7 @@ development: identity_pki_local_dev: true in_person_proofing_enabled: true in_person_proofing_enforce_tmx: true + in_person_proofing_opt_in_enabled: true in_person_send_proofing_notifications_enabled: true logins_per_ip_limit: 5 logo_upload_enabled: true From 7826defc1b7719ad48dd317a63fec42f6ca1dc6b Mon Sep 17 00:00:00 2001 From: John Maxwell Date: Fri, 26 Apr 2024 12:55:18 -0400 Subject: [PATCH 11/14] LG-13025 - ThreatMetrix check should fail if we don't have a ThreatMetrix session id. (#10452) * Specific return values for TMX id missing and PII missing cases. * Specs to ensure that we handle threatmetrix client field. - `VerifyInfoController` event logging includes the client - If we don't have a TM session id, return a unique client name - If we don't have PII to send to TM, return a unique client name - Continue to return the same client name if TM is disabled. We used to return the same client name for all three conditions. changelog: Internal,analytics and IdV,Improved handling and logging for ThreatMetrix requests. * Jmax/lg 13025 specs cleanup (#10486) Spec cleanup. Co-authored-by: Zach Margolis --------- Co-authored-by: Zach Margolis --- .../resolution/progressive_proofer.rb | 21 +- .../idv/verify_info_controller_spec.rb | 26 ++ spec/jobs/resolution_proofing_job_spec.rb | 6 +- .../resolution/progressive_proofer_spec.rb | 438 +++++++++--------- 4 files changed, 256 insertions(+), 235 deletions(-) diff --git a/app/services/proofing/resolution/progressive_proofer.rb b/app/services/proofing/resolution/progressive_proofer.rb index fb92d7d12be..4e901768540 100644 --- a/app/services/proofing/resolution/progressive_proofer.rb +++ b/app/services/proofing/resolution/progressive_proofer.rb @@ -94,9 +94,8 @@ def proof_with_threatmetrix_if_needed( # The API call will fail without a session ID, so do not attempt to make # it to avoid leaking data when not required. - return threatmetrix_disabled_result if threatmetrix_session_id.blank? - - return threatmetrix_disabled_result unless applicant_pii + return threatmetrix_id_missing_result if threatmetrix_session_id.blank? + return threatmetrix_pii_missing_result if applicant_pii.blank? ddp_pii = applicant_pii.dup ddp_pii[:threatmetrix_session_id] = threatmetrix_session_id @@ -202,6 +201,22 @@ def threatmetrix_disabled_result ) end + def threatmetrix_pii_missing_result + Proofing::DdpResult.new( + success: false, + client: 'tmx_pii_missing', + review_status: 'reject', + ) + end + + def threatmetrix_id_missing_result + Proofing::DdpResult.new( + success: false, + client: 'tmx_session_id_missing', + review_status: 'reject', + ) + end + def out_of_aamva_jurisdiction_result Proofing::StateIdResult.new( errors: {}, diff --git a/spec/controllers/idv/verify_info_controller_spec.rb b/spec/controllers/idv/verify_info_controller_spec.rb index d24645a0051..03868de9bdc 100644 --- a/spec/controllers/idv/verify_info_controller_spec.rb +++ b/spec/controllers/idv/verify_info_controller_spec.rb @@ -162,6 +162,8 @@ end context 'when proofing_device_profiling is enabled' do + let(:threatmetrix_client_id) { 'threatmetrix_client' } + let(:idv_result) do { context: { @@ -170,6 +172,7 @@ transaction_id: 1, review_status: review_status, response_body: { + client: threatmetrix_client_id, tmx_summary_reason_code: ['Identity_Negative_History'], }, }, @@ -209,6 +212,29 @@ ) get :show end + + # we use the client name for some error tracking, so make sure + # it gets through to the analytics event log. + it 'logs the analytics event, including the client' do + get :show + + expect(@analytics).to have_logged_event( + 'IdV: doc auth verify proofing results', + hash_including( + proofing_results: hash_including( + context: hash_including( + stages: hash_including( + threatmetrix: hash_including( + response_body: hash_including( + client: threatmetrix_client_id, + ), + ), + ), + ), + ), + ), + ) + end end context 'when threatmetrix response is No Result' do diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb index ca5ac68f823..639d69011fd 100644 --- a/spec/jobs/resolution_proofing_job_spec.rb +++ b/spec/jobs/resolution_proofing_job_spec.rb @@ -529,11 +529,11 @@ expect(result[:success]).to be true expect(result[:exception]).to be_nil expect(result[:timed_out]).to be false - expect(result[:threatmetrix_review_status]).to eq('pass') + expect(result[:threatmetrix_review_status]).to eq('reject') # result[:context][:stages][:threatmetrix] - expect(result_context_stages_threatmetrix[:success]).to eq(true) - expect(result_context_stages_threatmetrix[:client]).to eq('tmx_disabled') + expect(result_context_stages_threatmetrix[:success]).to eq(false) + expect(result_context_stages_threatmetrix[:client]).to eq('tmx_session_id_missing') expect(@threatmetrix_stub).to_not have_been_requested end diff --git a/spec/services/proofing/resolution/progressive_proofer_spec.rb b/spec/services/proofing/resolution/progressive_proofer_spec.rb index 8fb731b6759..f5ee93080e6 100644 --- a/spec/services/proofing/resolution/progressive_proofer_spec.rb +++ b/spec/services/proofing/resolution/progressive_proofer_spec.rb @@ -2,15 +2,38 @@ RSpec.describe Proofing::Resolution::ProgressiveProofer do let(:applicant_pii) { Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN } - let(:should_proof_state_id) { true } let(:ipp_enrollment_in_progress) { false } - let(:request_ip) { Faker::Internet.ip_v4_address } let(:threatmetrix_session_id) { SecureRandom.uuid } - let(:timer) { JobHelpers::Timer.new } - let(:user) { create(:user, :fully_registered) } - let(:instant_verify_proofer) { instance_double(Proofing::LexisNexis::InstantVerify::Proofer) } + + let(:instant_verify_proofing_success) { true } + let(:instant_verify_proofer_result) do + instance_double( + Proofing::Resolution::Result, + success?: instant_verify_proofing_success, + attributes_requiring_additional_verification: [:address], + ) + end + let(:instant_verify_proofer) do + instance_double( + Proofing::LexisNexis::InstantVerify::Proofer, + proof: instant_verify_proofer_result, + ) + end + + let(:aamva_proofer_result) { nil } + let(:aamva_proofer) { instance_double(Proofing::Aamva::Proofer, proof: aamva_proofer_result) } + + let(:threatmetrix_proofer) { instance_double(Proofing::LexisNexis::Ddp::Proofer, proof: nil) } + + let(:proof_id_address_with_lexis_nexis_if_needed_value) { nil } + let(:dcs_uuid) { SecureRandom.uuid } - let(:instance) { described_class.new(instant_verify_ab_test_discriminator: dcs_uuid) } + let(:instance) do + instance = described_class.new(instant_verify_ab_test_discriminator: dcs_uuid) + allow(instance).to receive(:user_can_pass_after_state_id_check?).and_call_original + instance + end + let(:state_id_address) do { address1: applicant_pii[:identity_doc_address1], @@ -21,6 +44,7 @@ zipcode: applicant_pii[:identity_doc_zipcode], } end + let(:residential_address) do { address1: applicant_pii[:address1], @@ -31,6 +55,7 @@ zipcode: applicant_pii[:zipcode], } end + let(:transformed_pii) do { first_name: 'FAKEY', @@ -47,84 +72,127 @@ } end + let(:ab_test_variables) { {} } + + let(:lniv) do + instance_double( + Idv::LexisNexisInstantVerify, + dcs_uuid, + workflow_ab_testing_variables: ab_test_variables, + ) + end + + let(:resolution_result) do + instance_double(Proofing::Resolution::Result, success?: true, errors: nil) + end + + def enable_threatmetrix + allow(FeatureManagement).to receive(:proofing_device_profiling_collecting_enabled?). + and_return(true) + end + + def disable_threatmetrix + allow(FeatureManagement).to receive(:proofing_device_profiling_collecting_enabled?). + and_return(false) + end + + def block_real_instant_verify_requests + allow(Proofing::LexisNexis::InstantVerify::VerificationRequest).to receive(:new) + end + + before do + # Remove the next two lines and un-comment the following line when + # the LexiNexis Instant Verify A/B test is ended + allow(Proofing::LexisNexis::InstantVerify::Proofer).to receive(:new). + and_return(instant_verify_proofer) + allow(Idv::LexisNexisInstantVerify).to receive(:new).and_return(lniv) + # uncomment after removing the above + # allow(instance).to receive(:resolution_proofer).and_return(instant_verify_proofer) + + allow(instance).to receive(:lexisnexis_ddp_proofer).and_return(threatmetrix_proofer) + allow(instance).to receive(:state_id_proofer).and_return(aamva_proofer) + + block_real_instant_verify_requests + end + describe '#proof' do before do allow(IdentityConfig.store).to receive(:proofer_mock_fallback).and_return(false) - allow(Proofing::LexisNexis::InstantVerify::VerificationRequest).to receive(:new) end + subject(:proof) do instance.proof( applicant_pii: applicant_pii, ipp_enrollment_in_progress: ipp_enrollment_in_progress, - request_ip: request_ip, - should_proof_state_id: should_proof_state_id, + request_ip: Faker::Internet.ip_v4_address, + should_proof_state_id: true, threatmetrix_session_id: threatmetrix_session_id, - timer: timer, - user_email: user.confirmed_email_addresses.first.email, + timer: JobHelpers::Timer.new, + user_email: Faker::Internet.email, ) end context 'remote proofing' do it 'returns a ResultAdjudicator' do - proofing_result = proof - - expect(proofing_result).to be_an_instance_of(Proofing::Resolution::ResultAdjudicator) - expect(proofing_result.same_address_as_id).to eq(nil) + expect(proof).to be_an_instance_of(Proofing::Resolution::ResultAdjudicator) + expect(proof.same_address_as_id).to eq(nil) end - let(:resolution_result) do - instance_double(Proofing::Resolution::Result) - end context 'ThreatMetrix is enabled' do - let(:threatmetrix_proofer) { instance_double(Proofing::LexisNexis::Ddp::Proofer) } + let(:proof_id_address_with_lexis_nexis_if_needed_value) { resolution_result } before do - allow(FeatureManagement).to receive(:proofing_device_profiling_collecting_enabled?). - and_return(true) + enable_threatmetrix allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_mock_enabled). and_return(false) - allow(instance).to receive(:lexisnexis_ddp_proofer).and_return(threatmetrix_proofer) - allow(instance).to receive(:proof_id_address_with_lexis_nexis_if_needed). - and_return(resolution_result) - allow(resolution_result).to receive(:success?).and_return(true) - allow(instant_verify_proofer).to receive(:proof) + proof end it 'makes a request to the ThreatMetrix proofer' do - expect(threatmetrix_proofer).to receive(:proof) - - subject + expect(threatmetrix_proofer).to have_received(:proof) end - context 'it lacks a session id' do + context 'session id is missing' do let(:threatmetrix_session_id) { nil } - it 'returns a disabled result' do - result = subject - device_profiling_result = result.device_profiling_result + it 'does not make a request to the ThreatMetrix proofer' do + expect(threatmetrix_proofer).not_to have_received(:proof) + end + + it 'returns a failed result' do + device_profiling_result = proof.device_profiling_result + + expect(device_profiling_result.success).to be(false) + expect(device_profiling_result.client).to eq('tmx_session_id_missing') + expect(device_profiling_result.review_status).to eq('reject') + end + end + + context 'pii is missing' do + let(:applicant_pii) { {} } + + it 'does not make a request to the ThreatMetrix proofer' do + expect(threatmetrix_proofer).not_to have_received(:proof) + end + + it 'returns a failed result' do + device_profiling_result = proof.device_profiling_result - expect(device_profiling_result.success).to be(true) - expect(device_profiling_result.client).to eq('tmx_disabled') - expect(device_profiling_result.review_status).to eq('pass') + expect(device_profiling_result.success).to be(false) + expect(device_profiling_result.client).to eq('tmx_pii_missing') + expect(device_profiling_result.review_status).to eq('reject') end end end context 'ThreatMetrix is disabled' do before do - allow(FeatureManagement).to receive(:proofing_device_profiling_collecting_enabled?). - and_return(false) - - allow(instance).to receive(:proof_id_address_with_lexis_nexis_if_needed). - and_return(resolution_result) - allow(resolution_result).to receive(:success?).and_return(true) - allow(instant_verify_proofer).to receive(:proof) + disable_threatmetrix end - it 'returns a disabled result' do - result = subject - device_profiling_result = result.device_profiling_result + it 'returns a disabled result' do + device_profiling_result = proof.device_profiling_result expect(device_profiling_result.success).to be(true) expect(device_profiling_result.client).to eq('tmx_disabled') @@ -132,63 +200,41 @@ end end + # Remove the mocks: + # `Proofing::LexisNexis::InstantVerify::Proofer#new` + # `Proofing::LexisNexis::InstantVerify#new` + # in the outermost `before` block after removing this context. context 'LexisNexis Instant Verify A/B test enabled' do - let(:residential_instant_verify_proof) do - instance_double(Proofing::Resolution::Result) - end - let(:instant_verify_workflow) { 'equitable_workflow' } let(:ab_test_variables) do { ab_testing_enabled: true, use_alternate_workflow: true, - instant_verify_workflow: instant_verify_workflow, + instant_verify_workflow: 'equitable_workflow', } end - before do - allow(instant_verify_proofer).to receive(:proof). - and_return(residential_instant_verify_proof) - allow(residential_instant_verify_proof).to receive(:success?).and_return(true) - end + before { proof } it 'uses the selected workflow' do - lniv = Idv::LexisNexisInstantVerify.new(dcs_uuid) - expect(lniv).to receive(:workflow_ab_testing_variables). - and_return(ab_test_variables) - expect(Idv::LexisNexisInstantVerify).to receive(:new). - and_return(lniv) - expect(Proofing::LexisNexis::InstantVerify::Proofer).to receive(:new). - with(hash_including(instant_verify_workflow: instant_verify_workflow)). - and_return(instant_verify_proofer) - - proof + expect(Proofing::LexisNexis::InstantVerify::Proofer).to( + have_received(:new).with( + hash_including( + instant_verify_workflow: 'equitable_workflow', + ), + ), + ) end end context 'remote flow does not augment pii' do - let(:aamva_proofer) { instance_double(Proofing::Aamva::Proofer) } - let(:id_address_instant_verify_proof) do - instance_double(Proofing::Resolution::Result) - end - - before do - allow(instance).to receive(:state_id_proofer).and_return(aamva_proofer) - allow(instance).to receive(:resolution_proofer).and_return(instant_verify_proofer) - allow(instant_verify_proofer).to receive(:proof). - and_return(id_address_instant_verify_proof) - allow(id_address_instant_verify_proof).to receive(:success?).and_return(true) - end - it 'proofs with untransformed pii' do - expect(aamva_proofer).to receive(:proof).with(applicant_pii) - - result = subject + proof - expect(result.same_address_as_id).to eq(nil) - expect(result.ipp_enrollment_in_progress).to eq(false) - # rubocop:disable Layout/LineLength - expect(result.residential_resolution_result.vendor_name).to eq('ResidentialAddressNotRequired') - # rubocop:enable Layout/LineLength + expect(aamva_proofer).to have_received(:proof).with(applicant_pii) + expect(proof.same_address_as_id).to eq(nil) + expect(proof.ipp_enrollment_in_progress).to eq(false) + expect(proof.residential_resolution_result.vendor_name). + to eq('ResidentialAddressNotRequired') end end end @@ -198,101 +244,81 @@ let(:applicant_pii) { Idp::Constants::MOCK_IDV_APPLICANT_SAME_ADDRESS_AS_ID } it 'returns a ResultAdjudicator' do - proofing_result = proof - - expect(proofing_result).to be_an_instance_of(Proofing::Resolution::ResultAdjudicator) - expect(proofing_result.same_address_as_id).to eq(applicant_pii[:same_address_as_id]) + expect(proof).to be_an_instance_of(Proofing::Resolution::ResultAdjudicator) + expect(proof.same_address_as_id).to eq(applicant_pii[:same_address_as_id]) end context 'residential address and id address are the same' do - let(:aamva_proofer) { instance_double(Proofing::Aamva::Proofer) } - let(:residential_instant_verify_proof) do - instance_double(Proofing::Resolution::Result) - end - before do - allow(instance).to receive(:with_state_id_address).and_return(transformed_pii) - allow(instance).to receive(:state_id_proofer).and_return(aamva_proofer) - allow(instance).to receive(:resolution_proofer).and_return(instant_verify_proofer) - allow(instant_verify_proofer).to receive(:proof). - and_return(residential_instant_verify_proof) - allow(residential_instant_verify_proof).to receive(:success?).and_return(true) - end - it 'only makes one request to LexisNexis InstantVerify' do - expect(instant_verify_proofer).to receive(:proof).exactly(:once) - expect(aamva_proofer).to receive(:proof) + proof - subject + expect(instant_verify_proofer).to have_received(:proof).exactly(:once) + expect(aamva_proofer).to have_received(:proof) end it 'produces a result adjudicator with correct information' do - expect(aamva_proofer).to receive(:proof) - - result = subject - expect(result.same_address_as_id).to eq('true') - expect(result.ipp_enrollment_in_progress).to eq(true) - expect(result.resolution_result).to eq(result.residential_resolution_result) + expect(proof.same_address_as_id).to eq('true') + expect(proof.ipp_enrollment_in_progress).to eq(true) + expect(proof.resolution_result).to eq(proof.residential_resolution_result) + expect(aamva_proofer).to have_received(:proof) end - it 'transforms PII correctly' do - expect(aamva_proofer).to receive(:proof).with(transformed_pii) + it 'uses the transformed PII' do + allow(instance).to receive(:with_state_id_address).and_return(transformed_pii) - result = subject - expect(result.same_address_as_id).to eq('true') - expect(result.ipp_enrollment_in_progress).to eq(true) - expect(result.resolution_result).to eq(result.residential_resolution_result) - expect(result.resolution_result.success?).to eq(true) + expect(proof.same_address_as_id).to eq('true') + expect(proof.ipp_enrollment_in_progress).to eq(true) + expect(proof.resolution_result).to eq(proof.residential_resolution_result) + expect(proof.resolution_result.success?).to eq(true) + expect(aamva_proofer).to have_received(:proof).with(transformed_pii) end context 'LexisNexis InstantVerify fails' do - let(:result_that_failed_instant_verify) do - instance_double(Proofing::Resolution::Result) - end + let(:instant_verify_proofing_success) { false } + before do - allow(instance).to receive(:proof_id_address_with_lexis_nexis_if_needed). - and_return(result_that_failed_instant_verify) - allow(instant_verify_proofer).to receive(:proof).with(hash_including(state_id_address)). - and_return(result_that_failed_instant_verify) - allow(instance).to receive(:user_can_pass_after_state_id_check?). - with(result_that_failed_instant_verify). - and_return(true) - allow(result_that_failed_instant_verify).to receive(:success?). - and_return(false) + allow(instant_verify_proofer_result).to( + receive( + :failed_result_can_pass_with_additional_verification?, + ).and_return(true), + ) end - context 'the failure can be covered by AAMVA' do - before do - allow(result_that_failed_instant_verify). - to receive(:attributes_requiring_additional_verification). - and_return([:address]) - end + it 'includes the state ID in the InstantVerify call' do + proof + + expect(instance).to have_received(:user_can_pass_after_state_id_check?). + with(instant_verify_proofer_result) + expect(instant_verify_proofer).to have_received(:proof). + with(hash_including(state_id_address)) + end + context 'the failure can be covered by AAMVA' do context 'it is not covered by AAMVA' do - let(:failed_aamva_proof) { instance_double(Proofing::StateIdResult) } - before do - allow(aamva_proofer).to receive(:proof).and_return(failed_aamva_proof) - allow(failed_aamva_proof).to receive(:verified_attributes).and_return([]) - allow(failed_aamva_proof).to receive(:success?).and_return(false) + let(:aamva_proofer_result) do + instance_double( + Proofing::StateIdResult, + verified_attributes: [], + success?: false, + ) end - it 'indicates the aamva check did not pass' do - result = subject - expect(result.state_id_result.success?).to eq(false) + it 'indicates the aamva check did not pass' do + expect(proof.state_id_result.success?).to eq(false) end end context 'it is covered by AAMVA' do - let(:successful_aamva_proof) { instance_double(Proofing::StateIdResult) } - before do - allow(aamva_proofer).to receive(:proof).and_return(successful_aamva_proof) - allow(successful_aamva_proof).to receive(:verified_attributes). - and_return([:address]) - allow(successful_aamva_proof).to receive(:success?).and_return(true) + let(:aamva_proofer_result) do + instance_double( + Proofing::StateIdResult, + verified_attributes: [:address], + success?: true, + ) end - it 'indicates aamva did pass' do - result = subject - expect(result.state_id_result.success?).to eq(true) + it 'indicates aamva did pass' do + expect(proof.state_id_result.success?).to eq(true) end end end @@ -300,51 +326,33 @@ context 'LexisNexis InstantVerify passes for residential address and id address' do context 'should proof with AAMVA' do - let(:id_resolution_that_passed_instant_verify) do - instance_double(Proofing::Resolution::Result) - end let(:residential_resolution_that_passed_instant_verify) do - instance_double(Proofing::Resolution::Result) + instance_double(Proofing::Resolution::Result, success?: true) end before do allow(instance).to receive(:proof_residential_address_if_needed). and_return(residential_resolution_that_passed_instant_verify) - allow(instance).to receive(:proof_id_address_with_lexis_nexis_if_needed). - and_return(id_resolution_that_passed_instant_verify) - allow(instant_verify_proofer).to receive(:proof). - with(hash_including(state_id_address)). - and_return(id_resolution_that_passed_instant_verify) - allow(instance).to receive(:user_can_pass_after_state_id_check?). - with(id_resolution_that_passed_instant_verify). - and_return(true) - allow(id_resolution_that_passed_instant_verify).to receive(:success?). - and_return(true) - allow(residential_resolution_that_passed_instant_verify).to receive(:success?). - and_return(true) end it 'makes a request to the AAMVA proofer' do - expect(aamva_proofer).to receive(:proof) + proof - subject + expect(aamva_proofer).to have_received(:proof) end context 'AAMVA proofing fails' do let(:aamva_client) { instance_double(Proofing::Aamva::VerificationClient) } - let(:failed_aamva_proof) do - instance_double(Proofing::StateIdResult) + let(:aamva_proofer_result) do + instance_double(Proofing::StateIdResult, success?: false) end + before do allow(Proofing::Aamva::VerificationClient).to receive(:new).and_return(aamva_client) - allow(failed_aamva_proof).to receive(:success?).and_return(false) end - it 'returns a result adjudicator that indicates the aamva proofing failed' do - allow(aamva_proofer).to receive(:proof).and_return(failed_aamva_proof) - result = subject - - expect(result.state_id_result.success?).to eq(false) + it 'returns a result adjudicator that indicates the aamva proofing failed' do + expect(proof.state_id_result.success?).to eq(false) end end end @@ -355,9 +363,7 @@ let(:residential_address_proof) do instance_double(Proofing::Resolution::Result) end - let(:resolution_result) do - instance_double(Proofing::Resolution::Result) - end + let(:ipp_enrollment_in_progress) { true } let(:applicant_pii) { Idp::Constants::MOCK_IDV_APPLICANT_STATE_ID_ADDRESS } let(:residential_address) do @@ -382,106 +388,80 @@ end context 'LexisNexis InstantVerify passes for residential address' do + let(:instant_verify_proofer_result) { residential_address_proof } + before do - allow(instance).to receive(:resolution_proofer).and_return(instant_verify_proofer) - allow(instant_verify_proofer).to receive(:proof).and_return(residential_address_proof) allow(residential_address_proof).to receive(:success?).and_return(true) end context 'LexisNexis InstantVerify passes for id address' do it 'makes two requests to the InstantVerify Proofer' do - expect(instant_verify_proofer).to receive(:proof). + proof + + expect(instant_verify_proofer).to have_received(:proof). with(hash_including(residential_address)). ordered - expect(instant_verify_proofer).to receive(:proof). + expect(instant_verify_proofer).to have_received(:proof). with(hash_including(state_id_address)). ordered - - subject end context 'AAMVA fails' do - let(:failed_aamva_proof) { instance_double(Proofing::StateIdResult) } - let(:aamva_proofer) { instance_double(Proofing::Aamva::Proofer) } - before do - allow(instance).to receive(:proof_id_with_aamva_if_needed). - and_return(failed_aamva_proof) - allow(aamva_proofer).to receive(:proof).and_return(failed_aamva_proof) - allow(failed_aamva_proof).to receive(:success?).and_return(false) - allow(resolution_result).to receive(:errors) + let(:aamva_proofer_result) do + instance_double(Proofing::StateIdResult, success?: false) end it 'returns the correct resolution results' do - result_adjudicator = subject - - expect(result_adjudicator.residential_resolution_result.success?).to be(true) - expect(result_adjudicator.resolution_result.success?).to be(true) - expect(result_adjudicator.state_id_result.success?).to be(false) + expect(proof.residential_resolution_result.success?).to be(true) + expect(proof.resolution_result.success?).to be(true) + expect(proof.state_id_result.success?).to be(false) end end end end context 'LexisNexis InstantVerify fails for residential address' do - let(:aamva_proofer) { instance_double(Proofing::Aamva::Proofer) } + let(:instant_verify_proofer_result) { residential_address_proof } before do - allow(instance).to receive(:state_id_proofer).and_return(aamva_proofer) allow(instance).to receive(:proof_residential_address_if_needed). and_return(residential_address_proof) - allow(instant_verify_proofer).to receive(:proof). - with(hash_including(residential_address)). - and_return(residential_address_proof) - allow(instance).to receive(:user_can_pass_after_state_id_check?). - with(residential_address_proof). - and_return(false) allow(residential_address_proof).to receive(:success?). and_return(false) end it 'does not make unnecessary calls' do - expect(aamva_proofer).to_not receive(:proof) - expect(instant_verify_proofer).to_not receive(:proof). - with(hash_including(state_id_address)) + proof - subject + expect(aamva_proofer).to_not have_received(:proof) + expect(instant_verify_proofer).to_not have_received(:proof) end end context 'LexisNexis InstantVerify fails for id address & passes for residential address' do - let(:aamva_proofer) { instance_double(Proofing::Aamva::Proofer) } let(:result_that_failed_instant_verify) do instance_double(Proofing::Resolution::Result) end before do - allow(instance).to receive(:state_id_proofer).and_return(aamva_proofer) - allow(instance).to receive(:proof_id_address_with_lexis_nexis_if_needed). - and_return(result_that_failed_instant_verify) allow(instant_verify_proofer).to receive(:proof).with(hash_including(state_id_address)). and_return(result_that_failed_instant_verify) end context 'the failure can be covered by AAMVA' do - let(:failed_aamva_proof) { instance_double(Proofing::StateIdResult) } - let(:aamva_proofer) { instance_double(Proofing::Aamva::Proofer) } before do - allow(instance).to receive(:resolution_proofer).and_return(instant_verify_proofer) allow(instant_verify_proofer).to receive(:proof).and_return(residential_address_proof) allow(residential_address_proof).to receive(:success?).and_return(true) - allow(instance).to receive(:user_can_pass_after_state_id_check?). - with(result_that_failed_instant_verify). - and_return(true) allow(result_that_failed_instant_verify). to receive(:attributes_requiring_additional_verification). and_return([:address]) - allow(instance).to receive(:state_id_proofer).and_return(aamva_proofer) + + proof end - it 'calls AAMVA' do - expect(aamva_proofer).to receive(:proof) - subject + it 'calls AAMVA' do + expect(aamva_proofer).to have_received(:proof) end end end From f441db9c64bdced4200d75e0c5a3d0946b52cd1e Mon Sep 17 00:00:00 2001 From: John Maxwell Date: Fri, 26 Apr 2024 13:02:59 -0400 Subject: [PATCH 12/14] LG-13037 Remove `mfa_expiration_interval` from sp session (#10504) * Extracted `#mfa_expiration_interval` from `ServiceProviderSession` to `RememberDeviceConcern, which is the only place it was actually used. * Created a spec for `RememberDeviceConcern` changelog: Internal,Vector of trust work,Pulled mfa_expiration_interval over to RememberDeviceConcern --- .../concerns/remember_device_concern.rb | 23 ++++- .../null_service_provider_session.rb | 4 - app/decorators/service_provider_session.rb | 28 +----- .../concerns/remember_device_concern_spec.rb | 87 +++++++++++++++++++ .../null_service_provider_session_spec.rb | 6 -- .../service_provider_session_spec.rb | 27 ------ 6 files changed, 112 insertions(+), 63 deletions(-) create mode 100644 spec/controllers/concerns/remember_device_concern_spec.rb diff --git a/app/controllers/concerns/remember_device_concern.rb b/app/controllers/concerns/remember_device_concern.rb index e147e3c10f7..c760dbd5ee7 100644 --- a/app/controllers/concerns/remember_device_concern.rb +++ b/app/controllers/concerns/remember_device_concern.rb @@ -19,7 +19,7 @@ def check_remember_device_preference return unless UserSessionContext.authentication_context?(context) return if remember_device_cookie.nil? - expiration_time = decorated_sp_session.mfa_expiration_interval + expiration_time = mfa_expiration_interval return unless remember_device_cookie.valid_for_user?( user: current_user, expiration_interval: expiration_time, @@ -39,7 +39,7 @@ def remember_device_cookie def remember_device_expired_for_sp? expired_for_interval?( current_user, - decorated_sp_session.mfa_expiration_interval, + mfa_expiration_interval, ) end @@ -53,8 +53,27 @@ def revoke_remember_device(user) ) end + def mfa_expiration_interval + aal_1_expiration = IdentityConfig.store.remember_device_expiration_hours_aal_1.hours + aal_2_expiration = IdentityConfig.store.remember_device_expiration_minutes_aal_2.minutes + + return aal_2_expiration if sp_aal > 1 + return aal_2_expiration if sp_ial > 1 + return aal_2_expiration if resolved_authn_context_result&.aal2? + + aal_1_expiration + end + private + def sp_aal + current_sp&.default_aal || 1 + end + + def sp_ial + current_sp&.ial || 1 + end + def expired_for_interval?(user, interval) return false unless has_remember_device_auth_event? remember_cookie = remember_device_cookie diff --git a/app/decorators/null_service_provider_session.rb b/app/decorators/null_service_provider_session.rb index f1c649e76ab..716d3876754 100644 --- a/app/decorators/null_service_provider_session.rb +++ b/app/decorators/null_service_provider_session.rb @@ -17,10 +17,6 @@ def cancel_link_url view_context.root_url end - def mfa_expiration_interval - IdentityConfig.store.remember_device_expiration_hours_aal_1.hours - end - def remember_device_default true end diff --git a/app/decorators/service_provider_session.rb b/app/decorators/service_provider_session.rb index d2e2c41a1ce..83c2d92490a 100644 --- a/app/decorators/service_provider_session.rb +++ b/app/decorators/service_provider_session.rb @@ -90,16 +90,6 @@ def sp_alert(section) end end - def mfa_expiration_interval - aal_1_expiration = IdentityConfig.store.remember_device_expiration_hours_aal_1.hours - aal_2_expiration = IdentityConfig.store.remember_device_expiration_minutes_aal_2.minutes - return aal_2_expiration if sp_aal > 1 - return aal_2_expiration if sp_ial > 1 - return aal_2_expiration if resolved_authn_context_result.aal2? - - aal_1_expiration - end - def requested_more_recent_verification? unless IdentityConfig.store.allowed_verified_within_providers.include?(sp_issuer) return false @@ -132,24 +122,14 @@ def current_user view_context&.current_user end - private + attr_reader :sp, :sp_session - attr_reader :sp, :view_context, :sp_session, :service_provider_request + private - def resolved_authn_context_result - @resolved_authn_context_result ||= AuthnContextResolver.new( - service_provider: sp, - vtr: sp_session[:vtr], - acr_values: sp_session[:acr_values], - ).resolve - end + attr_reader :view_context, :service_provider_request def sp_aal - sp.default_aal || 1 - end - - def sp_ial - sp.ial || 1 + sp&.default_aal || 1 end def request_url diff --git a/spec/controllers/concerns/remember_device_concern_spec.rb b/spec/controllers/concerns/remember_device_concern_spec.rb new file mode 100644 index 00000000000..c3f80e20cfe --- /dev/null +++ b/spec/controllers/concerns/remember_device_concern_spec.rb @@ -0,0 +1,87 @@ +require 'rails_helper' + +RSpec.describe RememberDeviceConcern do + let(:sp) { nil } + let(:raw_session) { {} } + + subject(:test_controller) do + test_controller_class = + Class.new(ApplicationController) do + include(RememberDeviceConcern) + + attr_reader :sp, :raw_session, :request + alias :sp_from_sp_session :sp + alias :sp_session :raw_session + + def initialize(sp, raw_session, request) + @sp = sp + @raw_session = raw_session + @request = request + end + end + + test_request = double( + 'test_request', + session: raw_session, + parameters: {}, + filtered_parameters: {}, + ) + + test_controller_class.new(sp, raw_session, test_request) + end + + describe '#mfa_expiration_interval' do + let(:expected_aal_1_expiration) { 720.hours } + let(:expected_aal_2_expiration) { 0.hours } + + context 'with no sp' do + let(:sp) { nil } + + it { expect(test_controller.mfa_expiration_interval).to eq(expected_aal_1_expiration) } + end + + context 'with an AAL2 sp' do + let(:sp) { build(:service_provider, default_aal: 2) } + + it { expect(test_controller.mfa_expiration_interval).to eq(expected_aal_2_expiration) } + end + + context 'with an IAL2 sp' do + let(:sp) { build(:service_provider, ial: 2) } + + it { expect(test_controller.mfa_expiration_interval).to eq(expected_aal_2_expiration) } + end + + context 'with an sp that is not AAL2 or IAL2' do + let(:sp) { build(:service_provider) } + + context 'and AAL1 requested' do + context 'with vtr' do + let(:raw_session) { { vtr: ['C1'] } } + + it { expect(test_controller.mfa_expiration_interval).to eq(30.days) } + end + + context 'with legacy acr' do + let(:raw_session) { { acr_values: Saml::Idp::Constants::AAL1_AUTHN_CONTEXT_CLASSREF } } + + it { expect(test_controller.mfa_expiration_interval).to eq(30.days) } + end + end + + context 'and AAL2 requested' do + context 'with vtr' do + let(:raw_session) { { vtr: ['C2'] } } + + it { expect(test_controller.mfa_expiration_interval).to eq(expected_aal_2_expiration) } + end + + context 'with legacy acr' do + let(:raw_session) { { acr_values: Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } } + + it { expect(test_controller.mfa_expiration_interval).to eq(expected_aal_2_expiration) } + end + end + end + end +end diff --git a/spec/decorators/null_service_provider_session_spec.rb b/spec/decorators/null_service_provider_session_spec.rb index c36fc8b16c1..55f373983fa 100644 --- a/spec/decorators/null_service_provider_session_spec.rb +++ b/spec/decorators/null_service_provider_session_spec.rb @@ -39,12 +39,6 @@ end end - describe '#mfa_expiration_interval' do - it 'returns the AAL1 expiration interval' do - expect(subject.mfa_expiration_interval).to eq(30.days) - end - end - describe '#requested_more_recent_verification' do it 'is false' do expect(subject.requested_more_recent_verification?).to eq(false) diff --git a/spec/decorators/service_provider_session_spec.rb b/spec/decorators/service_provider_session_spec.rb index 489c90fb87c..8106348ce5b 100644 --- a/spec/decorators/service_provider_session_spec.rb +++ b/spec/decorators/service_provider_session_spec.rb @@ -240,33 +240,6 @@ end end - describe '#mfa_expiration_interval' do - context 'with an AAL2 sp' do - before do - allow(sp).to receive(:default_aal).and_return(2) - end - - it { expect(subject.mfa_expiration_interval).to eq(0.hours) } - end - - context 'with an IAL2 sp' do - before do - allow(sp).to receive(:ial).and_return(2) - end - - it { expect(subject.mfa_expiration_interval).to eq(0.hours) } - end - - context 'with an sp that is not AAL2 or IAL2 and AAL1 requested' do - it { expect(subject.mfa_expiration_interval).to eq(30.days) } - end - - context 'with an sp that is not AAL2 or IAL2 and AAL2 requested' do - let(:sp_session) { { acr_values: Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } } - it { expect(subject.mfa_expiration_interval).to eq(0.hours) } - end - end - describe '#requested_more_recent_verification?' do let(:verified_within) { nil } let(:user) { create(:user) } From 22e298c8d9bc8a704b28ac5ce52c625ac8c4ecfc Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Mon, 29 Apr 2024 11:13:20 -0400 Subject: [PATCH 13/14] LG-12794 remove effective user (#10446) * remove effective user concern * remove effective_user from tests * add changelog changelog: Internal, IdV, Remove effective user * create method to check for hybrid user in analytics user * remove hybrid handoff specs from in_person spec * move hybrid handoff user to hybrid mobile concern * set analytics user to hybrid user in hybrid events * create new analytics call in hybrid mobile * override analytics_user instead of all analytics * add back in hybrid user functionality to usps_locations_controller * rename hybrid_user to current_or_hybrid_user. include concerns in frontendlog and usps locations * add in hybrid test for in_person_spec --- app/controllers/application_controller.rb | 3 +- app/controllers/concerns/effective_user.rb | 25 ------ .../hybrid_mobile/hybrid_mobile_concern.rb | 14 +++ app/controllers/frontend_log_controller.rb | 1 + .../in_person/usps_locations_controller.rb | 8 +- app/services/flow/flow_state_machine.rb | 2 +- app/services/idv/steps/doc_auth_base_step.rb | 6 -- .../concerns/effective_user_spec.rb | 86 ------------------- .../idv/document_capture_controller_spec.rb | 7 -- .../usps_locations_controller_spec.rb | 6 +- .../idv/session_errors_controller_spec.rb | 5 +- 11 files changed, 27 insertions(+), 136 deletions(-) delete mode 100644 app/controllers/concerns/effective_user.rb delete mode 100644 spec/controllers/concerns/effective_user_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 041c3c76b6e..508f3c8f0f2 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,7 +5,6 @@ class ApplicationController < ActionController::Base include BackupCodeReminderConcern include LocaleHelper include VerifySpAttributesConcern - include EffectiveUser include SecondMfaReminderConcern include TwoFactorAuthenticatableMethods @@ -73,7 +72,7 @@ def analytics end def analytics_user - effective_user || AnonymousUser.new + current_user || AnonymousUser.new end def irs_attempts_api_tracker diff --git a/app/controllers/concerns/effective_user.rb b/app/controllers/concerns/effective_user.rb deleted file mode 100644 index 5d19f6c1d89..00000000000 --- a/app/controllers/concerns/effective_user.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module EffectiveUser - def effective_user - return current_user if effective_user_id == current_user&.id - - user = User.find_by(id: effective_user_id) if effective_user_id - - if user.nil? - session.delete(:doc_capture_user_id) - return current_user - end - - user - end - - private - - def effective_user_id - [ - session[:doc_capture_user_id], - current_user&.id, - ].find(&:present?) - end -end diff --git a/app/controllers/concerns/idv/hybrid_mobile/hybrid_mobile_concern.rb b/app/controllers/concerns/idv/hybrid_mobile/hybrid_mobile_concern.rb index 50bc59f688a..0768109ef2a 100644 --- a/app/controllers/concerns/idv/hybrid_mobile/hybrid_mobile_concern.rb +++ b/app/controllers/concerns/idv/hybrid_mobile/hybrid_mobile_concern.rb @@ -8,6 +8,20 @@ module HybridMobileConcern include AcuantConcern include Idv::AbTestAnalyticsConcern + def analytics_user + current_or_hybrid_user || AnonymousUser.new + end + + def current_or_hybrid_user + return User.find_by(id: session[:doc_capture_user_id]) if !current_user && hybrid_user? + + current_user + end + + def hybrid_user? + session[:doc_capture_user_id].present? + end + def check_valid_document_capture_session if !document_capture_user # The user has not "logged in" to document capture via the EntryController diff --git a/app/controllers/frontend_log_controller.rb b/app/controllers/frontend_log_controller.rb index 24e120c69f3..7cdf975f826 100644 --- a/app/controllers/frontend_log_controller.rb +++ b/app/controllers/frontend_log_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class FrontendLogController < ApplicationController + include Idv::HybridMobile::HybridMobileConcern respond_to :json skip_before_action :verify_authenticity_token diff --git a/app/controllers/idv/in_person/usps_locations_controller.rb b/app/controllers/idv/in_person/usps_locations_controller.rb index ab5f275767e..1602ca83085 100644 --- a/app/controllers/idv/in_person/usps_locations_controller.rb +++ b/app/controllers/idv/in_person/usps_locations_controller.rb @@ -6,9 +6,9 @@ module Idv module InPerson class UspsLocationsController < ApplicationController include Idv::AvailabilityConcern + include Idv::HybridMobile::HybridMobileConcern include RenderConditionConcern include UspsInPersonProofing - include EffectiveUser check_or_render_not_found -> { InPersonConfig.enabled? } @@ -59,7 +59,7 @@ def proofer def add_proofing_component ProofingComponent. - create_or_find_by(user: effective_user). + create_or_find_by(user: current_or_hybrid_user). update(document_check: Idp::Constants::Vendors::USPS) end @@ -84,12 +84,12 @@ def handle_error(err) end def confirm_authenticated_for_api - render json: { success: false }, status: :unauthorized if !effective_user + render json: { success: false }, status: :unauthorized if !current_or_hybrid_user end def enrollment InPersonEnrollment.find_or_initialize_by( - user: effective_user, + user: current_or_hybrid_user, status: :establishing, profile: nil, ) diff --git a/app/services/flow/flow_state_machine.rb b/app/services/flow/flow_state_machine.rb index 074f08bcf97..d00569774a2 100644 --- a/app/services/flow/flow_state_machine.rb +++ b/app/services/flow/flow_state_machine.rb @@ -190,7 +190,7 @@ def analytics_properties step: current_step, step_count: current_flow_step_counts[current_step_name], analytics_id: @analytics_id, - irs_reproofing: effective_user&.reproof_for_irs?( + irs_reproofing: current_user&.reproof_for_irs?( service_provider: current_sp, ).present?, }.merge(flow.extra_analytics_properties). diff --git a/app/services/idv/steps/doc_auth_base_step.rb b/app/services/idv/steps/doc_auth_base_step.rb index 3f8ee103544..f0db538a151 100644 --- a/app/services/idv/steps/doc_auth_base_step.rb +++ b/app/services/idv/steps/doc_auth_base_step.rb @@ -48,12 +48,6 @@ def rate_limited_url idv_session_errors_rate_limited_url end - # Ideally we would not have to re-implement the EffectiveUser mixin - # but flow_session sometimes != controller#session - def effective_user - current_user || User.find(user_id_from_token) - end - def user_id current_user ? current_user.id : user_id_from_token end diff --git a/spec/controllers/concerns/effective_user_spec.rb b/spec/controllers/concerns/effective_user_spec.rb deleted file mode 100644 index 04e09132c24..00000000000 --- a/spec/controllers/concerns/effective_user_spec.rb +++ /dev/null @@ -1,86 +0,0 @@ -require 'rails_helper' - -RSpec.describe EffectiveUser, type: :controller do - controller ApplicationController do - include EffectiveUser - end - - let(:user) { create(:user) } - - describe '#effective_user' do - subject { controller.effective_user } - - context 'logged out' do - it 'returns nil' do - expect(subject).to be_nil - end - - context 'with valid doc capture session user id' do - before do - session[:doc_capture_user_id] = user.id - end - - it 'returns session user id' do - expect(subject).to eq user - end - end - - context 'with invalid doc capture session user id' do - before do - session[:doc_capture_user_id] = -1 - end - - it 'returns session user id' do - expect(subject).to be_nil - end - - it 'deletes the session key' do - subject - expect(session).not_to include(:doc_capture_user_id) - end - end - end - - context 'non-existent user' do - it 'returns session user id' do - expect(subject).to be_nil - end - end - - context 'logged in' do - before do - stub_sign_in user - end - - it 'returns session user id' do - expect(subject).to eq user - end - - context 'with valid doc capture session user id that is not the logged-in user' do - let(:doc_capture_user) { create(:user) } - - before do - session[:doc_capture_user_id] = doc_capture_user.id - end - - it 'returns doc capture user id' do - expect(subject).to eq(doc_capture_user) - end - end - - context 'with invalid doc capture session user id' do - before do - session[:doc_capture_user_id] = -1 - end - - it 'returns logged in user' do - expect(subject).to eql(user) - end - - it 'deletes the session key' do - expect { subject }.to(change { session[:doc_capture_user_id] }.to(nil)) - end - end - end - end -end diff --git a/spec/controllers/idv/document_capture_controller_spec.rb b/spec/controllers/idv/document_capture_controller_spec.rb index 57e58874208..984b89f40dc 100644 --- a/spec/controllers/idv/document_capture_controller_spec.rb +++ b/spec/controllers/idv/document_capture_controller_spec.rb @@ -200,13 +200,6 @@ end end - it 'does not use effective user outside of analytics_user in ApplicationControler' do - allow(subject).to receive(:analytics_user).and_return(subject.current_user) - expect(subject).not_to receive(:effective_user) - - get :show - end - context 'user is rate limited' do it 'redirects to rate limited page' do user = create(:user) diff --git a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb index bbbed4e4798..2d8010c7dee 100644 --- a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb +++ b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb @@ -285,16 +285,16 @@ context 'with hybrid user' do let(:user) { nil } - let(:effective_user) { create(:user) } + let(:hybrid_user) { create(:user) } before do - session[:doc_capture_user_id] = effective_user.id + session[:doc_capture_user_id] = hybrid_user.id end it 'writes the passed location to in-person enrollment associated with effective user' do response - enrollment = effective_user.reload.establishing_in_person_enrollment + enrollment = hybrid_user.reload.establishing_in_person_enrollment expect(enrollment.selected_location_details).to eq( selected_location[:usps_location].as_json, diff --git a/spec/controllers/idv/session_errors_controller_spec.rb b/spec/controllers/idv/session_errors_controller_spec.rb index bbb9f25949a..dc9ecd14b2b 100644 --- a/spec/controllers/idv/session_errors_controller_spec.rb +++ b/spec/controllers/idv/session_errors_controller_spec.rb @@ -106,10 +106,11 @@ context 'the user is in the hybrid flow' do render_views - let(:effective_user) { create(:user) } + let(:hybrid_user) { create(:user) } before do - session[:doc_capture_user_id] = effective_user.id + session[:doc_capture_user_id] = hybrid_user.id + allow(subject).to receive(:hybrid_user).and_return(hybrid_user) end it 'renders the error template' do From 5c758fd167df84170312ec58ab8daba842d4c730 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Mon, 29 Apr 2024 12:05:44 -0400 Subject: [PATCH 14/14] Cleanup `DocPiiReader#read_pii` (#10515) This commit is in response to feedback on #10498 [ref](https://github.com/18F/identity-idp/pull/10498#discussion_r1578427544). This method rebuilds the `#read_pii` method so it no longer maintains a map of keys to values and instead reads everything in-line where it can. I am hopeful this makes it a bit more succint and readable. changelog: Internal, LexisNexis TrueID Response Mixins, The LexisNexis TrueID client read_pii was refactored to improve readability by removing the map that mapped keys in the LexisNexis TrueID response to keys in the eventual pii_from_doc return value and replacing it with a StateID struct constructor call where the keys and values were mapped inline with the exception of the state_id_type value which needs to be independently computed to map it to the appropriate string based on the value in the LexisNexis TrueID response. --- .../doc_auth/lexis_nexis/doc_pii_reader.rb | 82 ++++++++----------- 1 file changed, 32 insertions(+), 50 deletions(-) diff --git a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb index 412ad1eede7..5aa9439d645 100644 --- a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb +++ b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb @@ -21,61 +21,43 @@ module DocPiiReader # @return [Pii::StateId, nil] def read_pii(true_id_product) - return nil 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]] + id_auth_field_data = true_id_product&.dig(:IDAUTH_FIELD_DATA) + return nil unless id_auth_field_data.present? - dob = parse_date( - year: pii.delete(:dob_year), - month: pii.delete(:dob_month), - day: pii.delete(:dob_day), - ) - pii[:dob] = 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 + state_id_type_slug = id_auth_field_data['Fields_DocumentClassName'] + state_id_type = DocAuth::Response::ID_TYPE_SLUGS[state_id_type_slug] - 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::StateId.new( + first_name: id_auth_field_data['Fields_FirstName'], + last_name: id_auth_field_data['Fields_Surname'], + middle_name: id_auth_field_data['Fields_MiddleName'], + address1: id_auth_field_data['Fields_AddressLine1'], + address2: id_auth_field_data['Fields_AddressLine2'], + city: id_auth_field_data['Fields_City'], + state: id_auth_field_data['Fields_State'], + zipcode: id_auth_field_data['Fields_PostalCode'], + dob: parse_date( + year: id_auth_field_data['Fields_DOB_Year'], + month: id_auth_field_data['Fields_DOB_Month'], + day: id_auth_field_data['Fields_DOB_Day'], + ), + state_id_expiration: parse_date( + year: id_auth_field_data['Fields_ExpirationDate_Year'], + month: id_auth_field_data['Fields_ExpirationDate_Month'], + day: id_auth_field_data['Fields_xpirationDate_Day'], # this is NOT a typo + ), + state_id_issued: parse_date( + year: id_auth_field_data['Fields_IssueDate_Year'], + month: id_auth_field_data['Fields_IssueDate_Month'], + day: id_auth_field_data['Fields_IssueDate_Day'], + ), + state_id_jurisdiction: id_auth_field_data['Fields_IssuingStateCode'], + state_id_number: id_auth_field_data['Fields_DocumentNumber'], + state_id_type: state_id_type, + issuing_country_code: id_auth_field_data['Fields_CountryCode'], ) - pii[:state_id_issued] = issued_date - - Pii::StateId.new(**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