diff --git a/app/controllers/sign_up/completions_controller.rb b/app/controllers/sign_up/completions_controller.rb index 86a321b7714..9da674a688c 100644 --- a/app/controllers/sign_up/completions_controller.rb +++ b/app/controllers/sign_up/completions_controller.rb @@ -132,7 +132,18 @@ def verified_at def pii_to_displayable_attributes { full_name: full_name, - social_security_number: pii[:ssn], + social_security_number: render_to_string( + partial: 'shared/masked_text', + locals: { + text: SsnFormatter.format(pii[:ssn]), + masked_text: SsnFormatter.format_masked(pii[:ssn]), + accessible_masked_text: t( + 'idv.accessible_labels.masked_ssn', + first_number: pii[:ssn][0], + last_number: pii[:ssn][-1], + ), + }, + ), address: address, birthdate: dob, phone: PhoneFormatter.format(pii[:phone].to_s), diff --git a/app/javascript/packages/masked-text-toggle/index.js b/app/javascript/packages/masked-text-toggle/index.js new file mode 100644 index 00000000000..e4fc4ae24c6 --- /dev/null +++ b/app/javascript/packages/masked-text-toggle/index.js @@ -0,0 +1,28 @@ +class MaskedTextToggle { + /** + * @param {HTMLInputElement} toggle + */ + constructor(toggle) { + this.elements = { + toggle, + texts: /** @type {NodeListOf} */ (document.querySelectorAll( + `#${toggle.getAttribute('aria-controls')} .masked-text__text`, + )), + }; + } + + bind() { + this.elements.toggle.addEventListener('change', () => this.toggleTextVisibility()); + this.toggleTextVisibility(); + } + + toggleTextVisibility() { + const { toggle, texts } = this.elements; + const isMasked = !toggle.checked; + texts.forEach((text) => { + text.classList.toggle('display-none', text.dataset.masked !== isMasked.toString()); + }); + } +} + +export default MaskedTextToggle; diff --git a/app/javascript/packages/masked-text-toggle/package.json b/app/javascript/packages/masked-text-toggle/package.json new file mode 100644 index 00000000000..6c94e97790c --- /dev/null +++ b/app/javascript/packages/masked-text-toggle/package.json @@ -0,0 +1,5 @@ +{ + "name": "@18f/identity-masked-text-toggle", + "private": true, + "version": "1.0.0" +} diff --git a/app/javascript/packs/masked-text-toggle.js b/app/javascript/packs/masked-text-toggle.js new file mode 100644 index 00000000000..ad14e181850 --- /dev/null +++ b/app/javascript/packs/masked-text-toggle.js @@ -0,0 +1,4 @@ +import MaskedTextToggle from '@18f/identity-masked-text-toggle'; + +const wrappers = document.querySelectorAll('.masked-text__toggle'); +wrappers.forEach((toggle) => new MaskedTextToggle(/** @type {HTMLInputElement} */ (toggle)).bind()); diff --git a/app/services/ssn_formatter.rb b/app/services/ssn_formatter.rb new file mode 100644 index 00000000000..9ce3d8c8573 --- /dev/null +++ b/app/services/ssn_formatter.rb @@ -0,0 +1,15 @@ +module SsnFormatter + def self.format(ssn) + normalized_ssn = normalize(ssn) + "#{normalized_ssn[0..2]}-#{normalized_ssn[3..4]}-#{normalized_ssn[5..8]}" + end + + def self.format_masked(ssn) + normalized_ssn = normalize(ssn) + "#{normalized_ssn[0]}**-**-***#{normalized_ssn[-1]}" + end + + def self.normalize(ssn) + ssn.to_s.gsub(/\D/, '')[0..8] + end +end diff --git a/app/views/idv/doc_auth/verify.html.erb b/app/views/idv/doc_auth/verify.html.erb index 7bcc4ccbbd1..b478ab19bd2 100644 --- a/app/views/idv/doc_auth/verify.html.erb +++ b/app/views/idv/doc_auth/verify.html.erb @@ -40,21 +40,22 @@ class: 'usa-button usa-button--unstyled', ) %> - <%= t('doc_auth.forms.ssn') %> -
- <%= tag.input value: flow_session[:pii_from_doc][:ssn], - class: 'block col-12 field password ssn ssn-toggle bg-white', - aria: { label: t('doc_auth.forms.ssn'), invalid: false, required: false }, - readonly: true, - maxlength: 11, - pattern: "^\d{3}-?\d{2}-?\d{4}$", - size: "11", - type: "password", - name: "doc_auth[ssn]", - id: "doc_auth_ssn" %> +
+ <%= t('doc_auth.forms.ssn') %>: + <%= render( + 'shared/masked_text', + text: SsnFormatter.format(flow_session[:pii_from_doc][:ssn]), + masked_text: SsnFormatter.format_masked(flow_session[:pii_from_doc][:ssn]), + accessible_masked_text: t( + 'idv.accessible_labels.masked_ssn', + first_number: flow_session[:pii_from_doc][:ssn][0], + last_number: flow_session[:pii_from_doc][:ssn][-1], + ), + toggle_label: t('forms.ssn.show'), + ) %>
-
+
<%= render 'shared/spinner_button', action_message: t('doc_auth.info.verifying'), class: 'grid-col-12 tablet:grid-col-6' do %> diff --git a/app/views/shared/_masked_text.html.erb b/app/views/shared/_masked_text.html.erb new file mode 100644 index 00000000000..045d63d0348 --- /dev/null +++ b/app/views/shared/_masked_text.html.erb @@ -0,0 +1,33 @@ +<%# +locals: +* id: Optional field identifier. +* text: Original unmasked text. +* masked_text: Masked text. +* accessible_masked_text: A version of masked text appropriate for assistive technology. +* toggle_label: (Optional) Text to show on toggle. If not given, no toggle will be shown. +%> +<% +id = local_assigns.fetch(:id, "masked-text-#{SecureRandom.hex(6)}") +checkbox_id = "#{id}-checkbox" +%> +<%= tag.span(id: id) do %> + + <%= accessible_masked_text %> + + + +<% end %> +<% if local_assigns[:toggle_label] %> +
+ <%= tag.input( + type: 'checkbox', + id: checkbox_id, + aria: { controls: id }, + class: 'masked-text__toggle usa-checkbox__input usa-checkbox__input--bordered', + ) %> + <%= tag.label(toggle_label, for: checkbox_id, class: 'usa-checkbox__label') %> +
+ <%= javascript_packs_tag_once 'masked-text-toggle' %> +<% end %> diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index a27b9d1ff72..291c2fe3149 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -10,8 +10,8 @@ en: status_move_closer: Move camera closer to document status_tap_to_capture: Automatic capture disabled buttons: - change_address: change - change_ssn: change + change_address: Change + change_ssn: Change continue: Continue start_over: Start over take_or_upload_picture: 'Take photo or @@ -134,7 +134,7 @@ en: review_issues: Check your images and try again secure_account: Secure your account selfie: Take a photo of yourself - ssn: Please enter your Social Security number. + ssn: Enter your Social Security number ssn_update: Update your Social Security number take_picture: Take a photo with a phone text_message: We sent a message to your phone @@ -142,7 +142,7 @@ en: upload_from_phone: Take a photo with a mobile phone to upload your ID upload_from_phone_liveness_enabled: Use a mobile phone to add your ID and take a photo of yourself upload_liveness_enabled: How would you like to verify your identity? - verify: Please verify your information + verify: Verify your information verify_identity: Verify your identity welcome: Verify your identity to securely access government services info: diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml index cf6383f0ece..39321f61c25 100644 --- a/config/locales/doc_auth/es.yml +++ b/config/locales/doc_auth/es.yml @@ -10,8 +10,8 @@ es: status_move_closer: Acerca la cámara al documento status_tap_to_capture: Captura automática desactivada buttons: - change_address: cambio - change_ssn: cambio + change_address: Cambio + change_ssn: Cambio continue: Continuar start_over: Comenzar de nuevo take_or_upload_picture: 'Toma una foto o @@ -160,7 +160,7 @@ es: review_issues: Revise sus imágenes e inténtelo de nuevo secure_account: Asegure su cuenta selfie: Tómese una foto - ssn: Por favor ingrese su número de Seguro Social. + ssn: Ingresa tu número de Seguro Social ssn_update: Actualice su número de Seguro Social take_picture: Toma una foto con un teléfono text_message: Enviamos un mensaje a su teléfono @@ -169,7 +169,7 @@ es: upload_from_phone: Tome una foto con un teléfono móvil para cargar su identificación upload_from_phone_liveness_enabled: Utilice un celular para agregar su documento de identidad y tomarse upload_liveness_enabled: '¿Cómo te gustaría verificar su identidad?' - verify: Por favor verifica tu información + verify: Verifica tus datos verify_identity: Verifique su identidad welcome: Verifique su identidad para acceder de forma segura a los servicios gubernamentales diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml index 70c9323fbef..ffb9be7a21c 100644 --- a/config/locales/doc_auth/fr.yml +++ b/config/locales/doc_auth/fr.yml @@ -10,8 +10,8 @@ fr: status_move_closer: Rapprochez l'appareil photo du document status_tap_to_capture: Capture automatique désactivée buttons: - change_address: changement - change_ssn: changement + change_address: Changement + change_ssn: Changement continue: Continuer start_over: Recommencer take_or_upload_picture: 'Prendre une photo ou @@ -165,7 +165,7 @@ fr: review_issues: Vérifiez vos images et essayez à nouveau secure_account: Sécuriser votre compte selfie: Prenez une photo de vous-même - ssn: S’il vous plaît entrez votre numéro de Sécurité Sociale. + ssn: Saisissez votre numéro de sécurité sociale ssn_update: Mettre à jour votre numéro de Sécurité Sociale take_picture: Prendre une photo avec un téléphone text_message: Nous avons envoyé un message à votre téléphone @@ -176,7 +176,7 @@ fr: upload_from_phone_liveness_enabled: Utilisez un téléphone portable pour ajouter votre pièce d’identité et prendre une photo de vous-même upload_liveness_enabled: Comment souhaitez-vous vérifier votre identité? - verify: S’il vous plaît vérifier vos informations + verify: Vérifier votre information verify_identity: Vérifier votre identité welcome: Vérifiez votre identité pour accéder en toute sécurité aux services gouvernementaux diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 99c5ed5fb33..062f7315589 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -1,6 +1,8 @@ --- en: idv: + accessible_labels: + masked_ssn: secure text, starting with %{first_number} and ending with %{last_number} buttons: cancel: Cancel and return to your profile continue_plain: Continue diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index 6931bdaa85a..e426777cc15 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -1,6 +1,9 @@ --- es: idv: + accessible_labels: + masked_ssn: texto seguro, comenzando con %{first_number} y terminando con + %{last_number} buttons: cancel: Cancele y regrese a su perfil continue_plain: Continuar diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index e66a1b05e8b..1f42c4fb038 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -1,6 +1,9 @@ --- fr: idv: + accessible_labels: + masked_ssn: Texte sécurisé, commençant par %{first_number} et finissant par + %{last_number} buttons: cancel: Annuler et retourner à votre profil continue_plain: Continuer diff --git a/spec/controllers/sign_up/completions_controller_spec.rb b/spec/controllers/sign_up/completions_controller_spec.rb index 016c84a6bfc..cade3937051 100644 --- a/spec/controllers/sign_up/completions_controller_spec.rb +++ b/spec/controllers/sign_up/completions_controller_spec.rb @@ -28,7 +28,7 @@ let(:user) do create(:user, profiles: [create(:profile, :verified, :active)]) end - let(:pii) { {} } + let(:pii) { { ssn: '123456789' } } before do stub_sign_in(user) @@ -48,7 +48,7 @@ end context 'with american-style birthday data' do - let(:pii) { { dob: '12/31/1970' } } + let(:pii) { { ssn: '123456789', dob: '12/31/1970' } } it 'renders data' do get :show @@ -57,7 +57,7 @@ end context 'with international style birthday data' do - let(:pii) { { dob: '1970-01-01' } } + let(:pii) { { ssn: '123456789', dob: '1970-01-01' } } it 'renders data' do get :show diff --git a/spec/features/idv/doc_auth/verify_step_spec.rb b/spec/features/idv/doc_auth/verify_step_spec.rb index a55527009cc..06f02ff74b1 100644 --- a/spec/features/idv/doc_auth/verify_step_spec.rb +++ b/spec/features/idv/doc_auth/verify_step_spec.rb @@ -20,15 +20,10 @@ expect(page).to have_content(t('doc_auth.headings.verify')) end - it 'can toggle viewing the ssn' do - input_with_ssn_value = "input[value='666-66-1234']" - expect(page).to have_selector(input_with_ssn_value), visible: false - - find('input.ssn-toggle').click - expect(page).to have_selector(input_with_ssn_value), visible: true - - find('input.ssn-toggle').click - expect(page).to have_selector(input_with_ssn_value), visible: false + it 'masks the ssn' do + expect(page).to have_text('6**-**-***4') + expect(page.find('.masked-text__text', text: '666-66-1234')). + to match_css('.display-none').or have_ancestor('.display-none') end it 'proceeds to the next page upon confirmation' do @@ -268,6 +263,19 @@ end end + it 'can toggle viewing the ssn' do + expect(page).to have_text('6**-**-***4') + expect(page).not_to have_text('666-66-1234') + + check t('forms.ssn.show'), allow_label_click: true + expect(page).to have_text('666-66-1234') + expect(page).not_to have_text('6**-**-***4') + + uncheck t('forms.ssn.show'), allow_label_click: true + expect(page).to have_text('6**-**-***4') + expect(page).not_to have_text('666-66-1234') + end + it 'proceeds to the next page upon confirmation' do click_idv_continue diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 012e4f1557f..49ec7727875 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -823,7 +823,7 @@ click_submit_default expect(current_path).to eq sign_up_completed_path - expect(page).to have_content('111223333') + expect(page).to have_content('1**-**-***3') click_agree_and_continue @@ -858,7 +858,7 @@ click_submit_default expect(current_path).to eq sign_up_completed_path - expect(page).to have_content('111223333') + expect(page).to have_content('1**-**-***3') click_agree_and_continue diff --git a/spec/javascripts/packages/masked-text-toggle/index-spec.js b/spec/javascripts/packages/masked-text-toggle/index-spec.js new file mode 100644 index 00000000000..4a740045cda --- /dev/null +++ b/spec/javascripts/packages/masked-text-toggle/index-spec.js @@ -0,0 +1,56 @@ +import MaskedTextToggle from '@18f/identity-masked-text-toggle'; +import { screen } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; + +describe('MaskedTextToggle', () => { + beforeEach(() => { + document.body.innerHTML = ` + + + secure text, starting with 1 and ending with 4 + + + + +
+ + +
+ `; + }); + + const getToggle = () => screen.getByRole('checkbox'); + const initialize = () => new MaskedTextToggle(getToggle()).bind(); + + it('sets initial visibility', () => { + userEvent.click(getToggle()); + initialize(); + + screen.getByText('123-12-1234', { ignore: '.display-none' }); + }); + + it('toggles masked texts', () => { + initialize(); + + expect(screen.getByText('123-12-1234').closest('.display-none')).to.exist(); + expect(screen.getByText('1**-**-***4').closest('.display-none')).to.not.exist(); + + userEvent.click(getToggle()); + + expect(screen.getByText('123-12-1234').closest('.display-none')).to.not.exist(); + expect(screen.getByText('1**-**-***4').closest('.display-none')).to.exist(); + }); +}); diff --git a/spec/services/ssn_formatter_spec.rb b/spec/services/ssn_formatter_spec.rb new file mode 100644 index 00000000000..1a0b37b4871 --- /dev/null +++ b/spec/services/ssn_formatter_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +describe SsnFormatter do + describe '.format' do + let(:ssn) { '' } + subject { SsnFormatter.format(ssn) } + + context 'numeric' do + let(:ssn) { 123456789 } + + it { should eq('123-45-6789') } + end + + context 'string with dashes' do + let(:ssn) { '123-45-6789' } + + it { should eq('123-45-6789') } + end + + context 'numeric string' do + let(:ssn) { '123456789' } + + it { should eq('123-45-6789') } + end + end + + describe '.format_masked' do + let(:ssn) { '' } + subject { SsnFormatter.format_masked(ssn) } + + context 'numeric' do + let(:ssn) { 123456789 } + + it { should eq('1**-**-***9') } + end + + context 'string with dashes' do + let(:ssn) { '123-45-6789' } + + it { should eq('1**-**-***9') } + end + + context 'numeric string' do + let(:ssn) { '123456789' } + + it { should eq('1**-**-***9') } + end + end + + describe '.normalize' do + let(:ssn) { '' } + subject { SsnFormatter.normalize(ssn) } + + context 'numeric' do + let(:ssn) { 123456789 } + + it { should eq('123456789') } + end + + context 'string with dashes' do + let(:ssn) { '123-45-6789' } + + it { should eq('123456789') } + end + + context 'numeric string' do + let(:ssn) { '123456789' } + + it { should eq('123456789') } + end + end +end diff --git a/spec/views/shared/_masked_text.html.erb_spec.rb b/spec/views/shared/_masked_text.html.erb_spec.rb new file mode 100644 index 00000000000..e0cfea9165f --- /dev/null +++ b/spec/views/shared/_masked_text.html.erb_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +describe 'shared/_masked_text.html.erb' do + let(:text) { 'password' } + let(:masked_text) { '********' } + let(:accessible_masked_text) { 'secure text' } + let(:id) { nil } + let(:toggle_label) { nil } + + before do + local_assigns = { + text: text, + masked_text: masked_text, + accessible_masked_text: accessible_masked_text, + } + local_assigns[:id] = id if id + local_assigns[:toggle_label] = toggle_label if toggle_label + + render('shared/masked_text', local_assigns) + end + + it 'renders texts' do + expect(rendered).to have_css('.display-none', text: text) + expect(rendered).to have_css('[aria-hidden]', text: masked_text) + expect(rendered).to have_css('.usa-sr-only', text: accessible_masked_text) + end + + context 'without toggle' do + let(:toggle_label) { nil } + + it 'does not render with toggle' do + expect(rendered).not_to have_css('input') + end + end + + context 'with toggle' do + let(:toggle_label) { 'Show password' } + + it 'renders with toggle' do + expect(rendered).to have_css('input[aria-controls]') + end + + context 'with custom id' do + let(:id) { 'custom-id' } + + it 'renders with custom id' do + expect(rendered).to have_css('input#custom-id-checkbox[aria-controls="custom-id"]') + end + end + end +end diff --git a/tsconfig.json b/tsconfig.json index 5d7e530fa73..31ea0f46682 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "app/javascript/packs/document-capture.jsx", "app/javascript/packs/form-steps-wait.jsx", "app/javascript/packs/form-validation.js", + "app/javascript/packs/masked-text-toggle.js", "app/javascript/packs/one-time-code-input.js", "app/javascript/packs/intl-tel-input.js", "app/javascript/packs/spinner-button.js",