From 3ce211eb429905522ddba43c4bcdb581e2f2ac2c Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 12 Nov 2024 09:28:29 -0500 Subject: [PATCH 01/23] Reset selected email session value on email deletion (#11492) changelog: Upcoming Features, Partner Email Selection, Reset selected email session value on email deletion --- app/controllers/users/emails_controller.rb | 1 + .../users/emails_controller_spec.rb | 91 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/app/controllers/users/emails_controller.rb b/app/controllers/users/emails_controller.rb index 0f8681fa729..09e24410d3e 100644 --- a/app/controllers/users/emails_controller.rb +++ b/app/controllers/users/emails_controller.rb @@ -99,6 +99,7 @@ def email_address def handle_successful_delete send_delete_email_notification + user_session.delete(:selected_email_id_for_linked_identity) flash[:success] = t('email_addresses.delete.success') create_user_event(:email_deleted) end diff --git a/spec/controllers/users/emails_controller_spec.rb b/spec/controllers/users/emails_controller_spec.rb index 30b667bfc62..bfc142718af 100644 --- a/spec/controllers/users/emails_controller_spec.rb +++ b/spec/controllers/users/emails_controller_spec.rb @@ -120,4 +120,95 @@ end end end + + describe '#delete' do + subject(:response) { delete :delete, params: params } + let(:user) { create(:user, :fully_registered, :with_multiple_emails) } + let(:params) { { id: user.email_addresses.take.id } } + + before do + stub_sign_in(user) + end + + it 'redirects to account page' do + expect(response).to redirect_to(account_url) + end + + context 'with invalid submisson' do + let(:user) { create(:user, :fully_registered) } + + it 'logs analytics' do + stub_analytics + + response + + expect(@analytics).to have_logged_event( + 'Email Deletion Requested', + success: false, + errors: {}, + ) + end + + it 'flashes error' do + response + + expect(flash[:error]).to eq(t('email_addresses.delete.failure')) + end + end + + context 'with valid submission' do + it 'logs analytics' do + stub_analytics + + response + + expect(@analytics).to have_logged_event( + 'Email Deletion Requested', + success: true, + errors: {}, + ) + end + + it 'notifies all confirmed email addresses, including the deleted' do + email_addresses = user.confirmed_email_addresses.to_a + + response + + expect_delivered_email_count(email_addresses.count) + email_addresses.each do |email_address| + expect_delivered_email( + to: [email_address.email], + subject: t('user_mailer.email_deleted.subject'), + ) + end + end + + it 'flashes success' do + response + + expect(flash[:success]).to eq(t('email_addresses.delete.success')) + end + + it 'tracks user event' do + expect { response }.to change { user.events.count }.by(1) + expect(user.events.last.event_type).to eq('email_deleted') + end + + it 'deletes the email address' do + expect { response }.to change { user.email_addresses.count }.by(-1) + end + + context 'with selected email for linked identity in session' do + before do + controller.user_session[:selected_email_id_for_linked_identity] = params[:id] + end + + it 'resets session value' do + response + + expect(controller.user_session[:selected_email_id_for_linked_identity]).to be_nil + end + end + end + end end From 5b17ca13e1431f413e6ce5621de9a00fd0c200fd Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 12 Nov 2024 10:04:33 -0500 Subject: [PATCH 02/23] LG-14888: Update Spanish reCAPTCHA strings link text (#11493) changelog: User-Facing Improvements, Localization, Improve Spanish translation for reCAPTCHA disclosure --- config/locales/es.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/locales/es.yml b/config/locales/es.yml index ac4c70204ad..fc28bbaf531 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1448,7 +1448,7 @@ notices.privacy.security_and_privacy_practices: Prácticas de seguridad y declar notices.resend_confirmation_email.success: Enviamos otro correo electrónico de confirmación. notices.session_cleared: Por su seguridad, borramos lo que usted ingresó si usted no pasa a una página nueva en %{minutes} minutos. notices.session_timedout: Cerramos su sesión. Por su seguridad, %{app_name} cierra su sesión cuando usted no pasa a una página nueva en %{minutes} minutos. -notices.sign_in.recaptcha.disclosure_statement_html: Este sitio está protegido por reCAPTCHA y se aplican %{google_policy_link_html} y %{google_tos_link_html} de Google. +notices.sign_in.recaptcha.disclosure_statement_html: Este sitio está protegido por reCAPTCHA y se aplican la %{google_policy_link_html} y las %{google_tos_link_html} de Google. notices.signed_up_and_confirmed.first_paragraph_end: con un vínculo para confirmar su dirección de correo electrónico. Siga el vínculo para continuar agregando este correo electrónico a su cuenta. notices.signed_up_and_confirmed.first_paragraph_start: Enviamos un correo electrónico a notices.signed_up_and_confirmed.no_email_sent_explanation_start: '¿No recibió un correo electrónico?' @@ -1755,9 +1755,9 @@ two_factor_authentication.piv_cac.nickname: Apodo two_factor_authentication.piv_cac.renamed: Se ha cambiado correctamente el nombre de su método PIV/CAC two_factor_authentication.please_try_again_html: Inténtelo de nuevo en %{countdown}. two_factor_authentication.read_about_two_factor_authentication: Lea sobre la autenticación de dos factores -two_factor_authentication.recaptcha.disclosure_statement_html: Este sitio está protegido por reCAPTCHA y se aplican %{google_policy_link_html} y %{google_tos_link_html} de Google. Lea %{login_tos_link_html} de %{app_name}. -two_factor_authentication.recaptcha.google_policy_link: la Política de privacidad -two_factor_authentication.recaptcha.google_tos_link: las Condiciones de servicio +two_factor_authentication.recaptcha.disclosure_statement_html: Este sitio está protegido por reCAPTCHA y se aplican la %{google_policy_link_html} y las %{google_tos_link_html} de Google. Lea %{login_tos_link_html} de %{app_name}. +two_factor_authentication.recaptcha.google_policy_link: Política de privacidad +two_factor_authentication.recaptcha.google_tos_link: Condiciones de servicio two_factor_authentication.recaptcha.login_tos_link: Condiciones de uso del servicio móvil two_factor_authentication.recommended: Recomendado two_factor_authentication.totp_header_text: Ingrese su código de la aplicación de autenticación From 47594d22a061c28886997412103c673363072755 Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 12 Nov 2024 10:40:07 -0500 Subject: [PATCH 03/23] Log A/B test buckets for Face/Touch recommend visited (#11496) * Log A/B test buckets for Face/Touch recommend visited changelog: Internal, A/B Tests, Log A/B test buckets for Face/Touch recommend visited * Include submitted --- config/initializers/ab_tests.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb index 969842c2a51..952a7071214 100644 --- a/config/initializers/ab_tests.rb +++ b/config/initializers/ab_tests.rb @@ -91,9 +91,9 @@ def self.all RECOMMEND_WEBAUTHN_PLATFORM_FOR_SMS_USER = AbTest.new( experiment_name: 'Recommend Face or Touch Unlock for SMS users', should_log: [ - 'Multi-Factor Authentication', - 'User Registration: MFA Setup Complete', - 'User Registration: 2FA Setup', + :webauthn_platform_recommended_visited, + :webauthn_platform_recommended_submitted, + :webauthn_setup_submitted, ].to_set, buckets: { recommend_for_account_creation: From d6ff721171ad8ae24ac8e8e76944b3b5026a3f8b Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 12 Nov 2024 10:58:33 -0500 Subject: [PATCH 04/23] LG-14754: Avoid focus loss on submit button when submitting form (#11482) * Fix aria-disabled button override styling for active buttons * Update JSDOM to latest version For ARIA reflection: https://github.com/jsdom/jsdom/pull/3655 * SpinnerButton: Disable on submission via aria-disabled changelog: Bug Fixes, Accessibility, Avoid focus loss on submit button when submitting form * SubmitButton: Disable on submission via aria-disabled * Deduplicate yarn.lock * Implement WebAuthn verification by binding submit event Rely on SubmitButtonElement to control whether submit event is emitted based on whether it's already being submitted --- app/assets/stylesheets/components/_btn.scss | 2 +- .../spinner-button-element.spec.ts | 56 +++- .../spinner-button/spinner-button-element.ts | 24 +- .../submit-button-element.spec.ts | 22 +- .../submit-button/submit-button-element.ts | 19 +- .../webauthn-verify-button-element.spec.ts | 15 +- .../webauthn-verify-button-element.ts | 23 +- package.json | 2 +- yarn.lock | 244 ++++++++---------- 9 files changed, 224 insertions(+), 183 deletions(-) diff --git a/app/assets/stylesheets/components/_btn.scss b/app/assets/stylesheets/components/_btn.scss index 452ac960179..6500169b809 100644 --- a/app/assets/stylesheets/components/_btn.scss +++ b/app/assets/stylesheets/components/_btn.scss @@ -8,7 +8,7 @@ } .usa-button:disabled.usa-button--active, -[aria-disabled='true'].usa-button--active { +.usa-button[aria-disabled='true'].usa-button--active { &:not( .usa-button--unstyled, .usa-button--secondary, diff --git a/app/javascript/packages/spinner-button/spinner-button-element.spec.ts b/app/javascript/packages/spinner-button/spinner-button-element.spec.ts index 816b81d69f1..8a45fad0911 100644 --- a/app/javascript/packages/spinner-button/spinner-button-element.spec.ts +++ b/app/javascript/packages/spinner-button/spinner-button-element.spec.ts @@ -75,18 +75,32 @@ describe('SpinnerButtonElement', () => { context('inside form', () => { it('disables button without preventing form handlers', async () => { const wrapper = createWrapper({ inForm: true }); - let didSubmit = false; - wrapper.form!.addEventListener('submit', (event) => { - didSubmit = true; - event.preventDefault(); - }); + const onSubmit = sandbox.stub().callsFake((event: SubmitEvent) => event.preventDefault()); + wrapper.form!.addEventListener('submit', onSubmit); const button = screen.getByRole('button', { name: 'Click Me' }); await userEvent.type(button, '{Enter}'); clock.tick(0); - expect(didSubmit).to.be.true(); - expect(button.hasAttribute('disabled')).to.be.true(); + expect(onSubmit).to.have.been.calledOnce(); + expect(button.ariaDisabled).to.equal('true'); + }); + + it('prevents duplicate submission', async () => { + const wrapper = createWrapper({ inForm: true }); + const onSubmit = sandbox.stub().callsFake((event: SubmitEvent) => event.preventDefault()); + wrapper.form!.addEventListener('submit', onSubmit); + const button = screen.getByRole('button', { name: 'Click Me' }); + + await userEvent.type(button, '{Enter}'); + clock.tick(0); + + expect(onSubmit).to.have.been.calledOnce(); + + await userEvent.type(button, '{Enter}'); + clock.tick(0); + + expect(onSubmit).to.have.been.calledOnce(); }); it('unbinds events when disconnected', () => { @@ -104,18 +118,32 @@ describe('SpinnerButtonElement', () => { context('with form inside (button_to)', () => { it('disables button without preventing form handlers', async () => { const wrapper = createWrapper({ isButtonTo: true }); - let didSubmit = false; - wrapper.form!.addEventListener('submit', (event) => { - didSubmit = true; - event.preventDefault(); - }); + const onSubmit = sandbox.stub().callsFake((event: SubmitEvent) => event.preventDefault()); + wrapper.form!.addEventListener('submit', onSubmit); const button = screen.getByRole('button', { name: 'Click Me' }); await userEvent.type(button, '{Enter}'); clock.tick(0); - expect(didSubmit).to.be.true(); - expect(button.hasAttribute('disabled')).to.be.true(); + expect(onSubmit).to.have.been.calledOnce(); + expect(button.ariaDisabled).to.equal('true'); + }); + + it('prevents duplicate submission', async () => { + const wrapper = createWrapper({ isButtonTo: true }); + const onSubmit = sandbox.stub().callsFake((event: SubmitEvent) => event.preventDefault()); + wrapper.form!.addEventListener('submit', onSubmit); + const button = screen.getByRole('button', { name: 'Click Me' }); + + await userEvent.type(button, '{Enter}'); + clock.tick(0); + + expect(onSubmit.callCount).equal(1); + + await userEvent.type(button, '{Enter}'); + clock.tick(0); + + expect(onSubmit.callCount).equal(1); }); }); diff --git a/app/javascript/packages/spinner-button/spinner-button-element.ts b/app/javascript/packages/spinner-button/spinner-button-element.ts index b91b052e938..3b9c06486be 100644 --- a/app/javascript/packages/spinner-button/spinner-button-element.ts +++ b/app/javascript/packages/spinner-button/spinner-button-element.ts @@ -13,6 +13,7 @@ export class SpinnerButtonElement extends HTMLElement { connectedCallback() { this.form = this.querySelector('form') || this.closest('form'); + this.button.addEventListener('click', this.#preventDefaultIfSpinning); this.addEventListener('spinner.start', () => this.toggleSpinner(true)); this.addEventListener('spinner.stop', () => this.toggleSpinner(false)); @@ -40,6 +41,10 @@ export class SpinnerButtonElement extends HTMLElement { return this.querySelector('.usa-button')!; } + get isSpinning(): boolean { + return this.classList.contains('spinner-button--spinner-active'); + } + get actionMessage(): HTMLElement { return this.querySelector('.spinner-button__action-message')!; } @@ -62,9 +67,9 @@ export class SpinnerButtonElement extends HTMLElement { this.button.classList.toggle('usa-button--active', isVisible); if (isVisible) { - this.button.setAttribute('disabled', ''); + this.button.setAttribute('aria-disabled', 'true'); } else { - this.button.removeAttribute('disabled'); + this.button.removeAttribute('aria-disabled'); } if (this.actionMessage) { @@ -73,16 +78,19 @@ export class SpinnerButtonElement extends HTMLElement { window.clearTimeout(this.#longWaitTimeout); if (isVisible && Number.isFinite(this.longWaitDurationMs)) { - this.#longWaitTimeout = window.setTimeout( - () => this.handleLongWait(), - this.longWaitDurationMs, - ); + this.#longWaitTimeout = window.setTimeout(this.#handleLongWait, this.longWaitDurationMs); } } - handleLongWait() { + #handleLongWait = () => { this.actionMessage?.classList.remove('usa-sr-only'); - } + }; + + #preventDefaultIfSpinning = (event: MouseEvent) => { + if (this.isSpinning) { + event.preventDefault(); + } + }; } declare global { diff --git a/app/javascript/packages/submit-button/submit-button-element.spec.ts b/app/javascript/packages/submit-button/submit-button-element.spec.ts index 54e3dc11f4c..49416c704cb 100644 --- a/app/javascript/packages/submit-button/submit-button-element.spec.ts +++ b/app/javascript/packages/submit-button/submit-button-element.spec.ts @@ -1,3 +1,4 @@ +import { mock } from 'node:test'; import { screen } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import './submit-button-element'; @@ -24,10 +25,29 @@ describe('SubmitButtonElement', () => { await userEvent.click(button); - expect(button.disabled).to.be.true(); + expect(button.ariaDisabled).to.equal('true'); expect(button.classList.contains('usa-button--active')).to.be.true(); }); + it('prevents duplicate submissions', async () => { + document.body.innerHTML = ` +
`; + + const button = screen.getByRole('button') as HTMLButtonElement; + const form = button.closest('form') as HTMLFormElement; + const onSubmit = mock.fn((event: SubmitEvent) => event.preventDefault()); + form.addEventListener('submit', onSubmit); + + await userEvent.click(button); + expect(onSubmit.mock.callCount()).to.equal(1); + await userEvent.click(button); + expect(onSubmit.mock.callCount()).to.equal(1); + }); + it('does not activate if form validation prevents submission', async () => { document.body.innerHTML = `