diff --git a/.about.yml b/.about.yml deleted file mode 100644 index 233a0a267b6..00000000000 --- a/.about.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: 18f-identity -full_name: Consumer Identity Services -description: 18F development of consumer identity solutions -impact: Make it easy for citizens to verify their identity in order to obtain information or services from government agencies. -owner_type: project -stage: alpha -testable: false -partners: - - GSA, USDS, Various Agencies -contact: - - url: mailto:diego.mayer@gsa.gov - text: diego.mayer@gsa.gov -type: app diff --git a/Gemfile b/Gemfile index 12b135de834..13904f3783b 100644 --- a/Gemfile +++ b/Gemfile @@ -92,7 +92,7 @@ group :development, :test do gem 'erb_lint', '~> 0.1.0', require: false gem 'i18n-tasks', '>= 0.9.31' gem 'knapsack' - gem 'nokogiri', '~> 1.13.2' + gem 'nokogiri', '~> 1.13.4' gem 'parallel_tests' gem 'pg_query', require: false gem 'pry-byebug' diff --git a/Gemfile.lock b/Gemfile.lock index 065111e5e41..b358894f075 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -386,7 +386,7 @@ GEM net-ssh (6.1.0) newrelic_rpm (8.5.0) nio4r (2.5.8) - nokogiri (1.13.3) + nokogiri (1.13.4) mini_portile2 (~> 2.8.0) racc (~> 1.4) notiffany (0.1.3) @@ -732,7 +732,7 @@ DEPENDENCIES multiset net-sftp newrelic_rpm (~> 8.0) - nokogiri (~> 1.13.2) + nokogiri (~> 1.13.4) octokit parallel_tests pg diff --git a/app/assets/images/alert/forgot.svg b/app/assets/images/status/info-question.svg similarity index 100% rename from app/assets/images/alert/forgot.svg rename to app/assets/images/status/info-question.svg diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index 9a014f10449..eb8c42e8c6e 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -1,4 +1,3 @@ -@import 'variables/colors'; @import 'variables/app'; @import 'required'; @import 'design-system-waiting-room'; diff --git a/app/assets/stylesheets/components/_card.scss b/app/assets/stylesheets/components/_card.scss index f37a211e433..116d9f274dc 100644 --- a/app/assets/stylesheets/components/_card.scss +++ b/app/assets/stylesheets/components/_card.scss @@ -1,5 +1,5 @@ .card { - background-color: $white; + background-color: color('white'); max-width: $container-skinny-width; &-wide { diff --git a/app/assets/stylesheets/components/_file-input.scss b/app/assets/stylesheets/components/_file-input.scss index d5b154655b3..a313b69dc97 100644 --- a/app/assets/stylesheets/components/_file-input.scss +++ b/app/assets/stylesheets/components/_file-input.scss @@ -74,7 +74,7 @@ } .usa-file-input__input:not([disabled]):focus { - outline: 3px solid $blue; + outline: 3px solid color('primary'); outline-offset: 6px; } diff --git a/app/assets/stylesheets/components/_form.scss b/app/assets/stylesheets/components/_form.scss index 6d3abac8ede..6e036476fd2 100644 --- a/app/assets/stylesheets/components/_form.scss +++ b/app/assets/stylesheets/components/_form.scss @@ -27,7 +27,7 @@ textarea { border-width: $border-width; border-color: $border-color; border-radius: $border-radius; - color: $gray; + color: color('ink'); font-weight: $bold-font-weight; &[type='number'], @@ -36,8 +36,8 @@ textarea { } &:focus { - border-color: $field-focus-color; - box-shadow: 0 0 0 2px rgba($field-focus-color, 0.5); + border-color: color('primary'); + box-shadow: 0 0 0 2px rgba(color('primary'), 0.5); outline: none; } diff --git a/app/assets/stylesheets/components/_full-screen.scss b/app/assets/stylesheets/components/_full-screen.scss deleted file mode 100644 index 2d931910cf6..00000000000 --- a/app/assets/stylesheets/components/_full-screen.scss +++ /dev/null @@ -1,58 +0,0 @@ -.has-full-screen-overlay { - overflow: hidden; -} - -.full-screen { - bottom: 0; - left: 0; - position: fixed; - right: 0; - top: 0; - z-index: 1000; -} - -.full-screen-close-button { - background-color: rgba(color('base-darkest'), 0.7); - border-radius: 0; - position: absolute; - right: 0; - top: 0; - width: auto; - z-index: 10; - - &:hover, - &:focus { - background-color: color('base-darker'); - } -} - -.full-screen-close-icon { - height: 1rem; - width: 1rem; -} - -// css for alternative fallback flow, that should hopefully be quickly retired -.full-screen-fallback { - background-color: #ffffff; - height: 100%; - width: 100%; -} - -.full-screen-close-button-fallback { - height: 2rem; - margin: 1rem; - width: 2rem; - z-index: 1; -} - -.full-screen-close-icon-fallback { - height: 1rem; - margin: 0.5rem; - width: 1rem; -} - -.full-screen-canvas-fallback { - position: relative; - width: 100%; - z-index: 0; -} diff --git a/app/assets/stylesheets/components/_i18n-dropdown.scss b/app/assets/stylesheets/components/_i18n-dropdown.scss index 2bd4b895ea6..649079631e8 100644 --- a/app/assets/stylesheets/components/_i18n-dropdown.scss +++ b/app/assets/stylesheets/components/_i18n-dropdown.scss @@ -16,7 +16,7 @@ } .i18n-mobile-dropdown { - background-color: $blue-light; + background-color: color('primary-lighter'); bottom: 100%; left: 0; position: absolute; @@ -27,7 +27,7 @@ position: relative; &.focused { - background-color: $blue; + background-color: color('primary'); border-radius: 0; margin-bottom: 0; margin-top: 0; @@ -46,6 +46,6 @@ } .language-mobile-button { - background-color: $blue-light; + background-color: color('primary-lighter'); border: 0; } diff --git a/app/assets/stylesheets/components/_list.scss b/app/assets/stylesheets/components/_list.scss index 6edcfef972c..5be58d778d2 100644 --- a/app/assets/stylesheets/components/_list.scss +++ b/app/assets/stylesheets/components/_list.scss @@ -1,22 +1,3 @@ -@mixin color-list($color: $yellow) { - list-style: none; - padding: 0; - - li { - padding-left: 1rem; - text-indent: -1rem; - - &::before { - color: $color; - content: '•'; - font-size: 2rem; - line-height: 1.5rem; - padding-right: 0.5rem; - vertical-align: -3px; - } - } -} - .success-bullets { .success-bullet { padding: 1rem 1rem 1rem 0; @@ -35,15 +16,3 @@ } } } - -.red-dots { - @include color-list($red); -} - -.teal-dots { - @include color-list($teal); -} - -.yellow-dots { - @include color-list($yellow); -} diff --git a/app/assets/stylesheets/components/_modal.scss b/app/assets/stylesheets/components/_modal.scss index 9eb3deb9d00..98cefce9c5c 100644 --- a/app/assets/stylesheets/components/_modal.scss +++ b/app/assets/stylesheets/components/_modal.scss @@ -74,7 +74,7 @@ } hr { - border-color: $teal; + border-color: color('info'); } } @@ -84,7 +84,7 @@ } hr { - border-color: $yellow; + border-color: color('warning'); } } diff --git a/app/assets/stylesheets/components/_password-toggle.scss b/app/assets/stylesheets/components/_password-toggle.scss index e83e3d190a1..39a9d11312f 100644 --- a/app/assets/stylesheets/components/_password-toggle.scss +++ b/app/assets/stylesheets/components/_password-toggle.scss @@ -1,8 +1,22 @@ -.password-toggle__toggle { - @include u-pin-right; - top: -24px; +lg-password-toggle { + display: block; + position: relative; - .usa-checkbox__label { - @include u-margin-y(0); + &.password-toggle--toggle-top .password-toggle__toggle-label { + @include u-pin-right; + top: -24px; } + + &.password-toggle--toggle-bottom .validated-field__input-wrapper { + margin-bottom: units(2); + } +} + +.password-toggle__toggle-wrapper { + display: flex; + justify-content: end; +} + +.password-toggle__toggle-label.usa-checkbox__label { + @include u-margin-y(0); } diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss index 54a41872b5b..5d1575dd82f 100644 --- a/app/assets/stylesheets/components/all.scss +++ b/app/assets/stylesheets/components/all.scss @@ -20,7 +20,6 @@ @import 'personal-key'; @import 'spinner-button'; @import 'spinner-dots'; -@import 'full-screen'; @import 'step-indicator'; @import 'troubleshooting-options'; @import 'i18n-dropdown'; diff --git a/app/assets/stylesheets/email.css.scss b/app/assets/stylesheets/email.css.scss index 2921d196acd..f1ae38d61e7 100644 --- a/app/assets/stylesheets/email.css.scss +++ b/app/assets/stylesheets/email.css.scss @@ -1,4 +1,3 @@ -@import 'variables/colors'; @import 'variables/email'; @import 'foundation-emails/scss/foundation-emails'; diff --git a/app/assets/stylesheets/variables/_app.scss b/app/assets/stylesheets/variables/_app.scss index f029af97194..29152ee8a55 100644 --- a/app/assets/stylesheets/variables/_app.scss +++ b/app/assets/stylesheets/variables/_app.scss @@ -25,8 +25,6 @@ $border-radius-xl: 16px !default; $border-radius-xxl: 24px !default; $border-color: #cedced !default; -$field-focus-color: $blue !default; - $container-skinny-width: 620px !default; $container-xskinny-width: 416px !default; $container-xxskinny-width: 296px !default; diff --git a/app/assets/stylesheets/variables/_colors.scss b/app/assets/stylesheets/variables/_colors.scss deleted file mode 100644 index 421f741d0b0..00000000000 --- a/app/assets/stylesheets/variables/_colors.scss +++ /dev/null @@ -1,11 +0,0 @@ -$blue: #0071bb !default; -$blue-light: #ebf3fa !default; -$navy: #112e51 !default; -$teal: #00bfe7 !default; -$yellow: #e1ce28 !default; -$red: #e21c3d !default; -$white: #ffffff !default; -$gray: #5b616a !default; -$black: #111111 !default; -$red: #ff0000 !default; -$red-lightest: #fff7f8 !default; diff --git a/app/assets/stylesheets/variables/_email.scss b/app/assets/stylesheets/variables/_email.scss index 619fd4c0d5f..e0a1aa7b02f 100644 --- a/app/assets/stylesheets/variables/_email.scss +++ b/app/assets/stylesheets/variables/_email.scss @@ -15,14 +15,20 @@ // 1. Global // --------- -$primary-color: $blue; -$secondary-color: $navy; +$primary-color: #0071bb; +$secondary-color: #112e51; $success-color: #3adb76; $warning-color: #ffae00; $alert-color: #ec5840; $light-gray: #f3f3f3; +$black: #111111; +$white: #ffffff; +$gray: #5b616a; $medium-gray: #cacaca; $dark-gray: #212121; +$blue-light: #ebf3fa; +$red: #e21c3d; +$red-lightest: #fff7f8; $pre-color: #ff6908; $global-width: 580px; $global-width-small: 95%; diff --git a/app/components/password_toggle_component.html.erb b/app/components/password_toggle_component.html.erb new file mode 100644 index 00000000000..5875ef810a3 --- /dev/null +++ b/app/components/password_toggle_component.html.erb @@ -0,0 +1,27 @@ +<%= content_tag(:'lg-password-toggle', class: css_class) do %> + <%= render ValidatedFieldComponent.new( + form: form, + name: :password, + type: :password, + label: label, + **field_options, + input_html: field_options[:input_html].to_h.merge( + id: input_id, + class: ['password-toggle__input', *field_options.dig(:input_html, :class)], + ), + ) %> +
+ + +
+<% end %> diff --git a/app/components/password_toggle_component.rb b/app/components/password_toggle_component.rb new file mode 100644 index 00000000000..7b693f64c6f --- /dev/null +++ b/app/components/password_toggle_component.rb @@ -0,0 +1,32 @@ +class PasswordToggleComponent < BaseComponent + attr_reader :form, :label, :toggle_label, :toggle_position, :field_options + + def initialize( + form:, + label: t('components.password_toggle.label'), + toggle_label: t('components.password_toggle.toggle_label'), + toggle_position: :top, + **field_options + ) + @form = form + @label = label + @toggle_label = toggle_label + @toggle_position = toggle_position + @field_options = field_options + end + + def css_class + classes = [] + classes << 'password-toggle--toggle-top' if toggle_position == :top + classes << 'password-toggle--toggle-bottom' if toggle_position == :bottom + classes + end + + def toggle_id + "password-toggle-#{unique_id}" + end + + def input_id + "password-toggle-input-#{unique_id}" + end +end diff --git a/app/components/password_toggle_component.ts b/app/components/password_toggle_component.ts new file mode 100644 index 00000000000..f5e3ff3cfc3 --- /dev/null +++ b/app/components/password_toggle_component.ts @@ -0,0 +1,3 @@ +import { PasswordToggleElement } from '@18f/identity-password-toggle-element'; + +customElements.define('lg-password-toggle', PasswordToggleElement); diff --git a/app/components/status_page_component.rb b/app/components/status_page_component.rb index 4824cf0761b..ab58e29554e 100644 --- a/app/components/status_page_component.rb +++ b/app/components/status_page_component.rb @@ -1,7 +1,8 @@ class StatusPageComponent < BaseComponent ICONS = { - warning: [], - error: [:lock], + info: [:question], + warning: [nil], + error: [nil, :lock], }.freeze renders_one :header, ::PageHeadingComponent @@ -17,7 +18,7 @@ def initialize(status: :error, icon: nil) raise ArgumentError, "`status` #{status} is invalid, expected one of #{ICONS.keys}" end - if icon && !ICONS[status].include?(icon) + if !ICONS[status].include?(icon) raise ArgumentError, "`icon` #{icon} is invalid, expected one of #{ICONS[status]}" end @@ -31,6 +32,7 @@ def icon_src def icon_alt # i18n-tasks-use t('components.status_page.icons.error') + # i18n-tasks-use t('components.status_page.icons.question') # i18n-tasks-use t('components.status_page.icons.warning') # i18n-tasks-use t('components.status_page.icons.lock') t(icon || status, scope: [:components, :status_page, :icons]) diff --git a/app/components/validated_field_component.html.erb b/app/components/validated_field_component.html.erb index 2e4043f7829..a52c89c3478 100644 --- a/app/components/validated_field_component.html.erb +++ b/app/components/validated_field_component.html.erb @@ -18,7 +18,10 @@ class: [*tag_options.dig(:input_html, :class), 'validated-field__input'], aria: { invalid: false, - describedby: "validated-field-error-#{unique_id}", + describedby: [ + *tag_options.dig(:input_html, :aria, :describedby), + "validated-field-error-#{unique_id}", + ], }, }, error_html: { id: "validated-field-error-#{unique_id}" }, diff --git a/app/controllers/account_reset/cancel_controller.rb b/app/controllers/account_reset/cancel_controller.rb index 40ffc79b18d..6c71547a1f7 100644 --- a/app/controllers/account_reset/cancel_controller.rb +++ b/app/controllers/account_reset/cancel_controller.rb @@ -26,7 +26,7 @@ def create private def track_event(result) - analytics.track_event(Analytics::ACCOUNT_RESET, result.to_h) + analytics.account_reset(**result.to_h) end def handle_valid_token diff --git a/app/controllers/event_disavowal_controller.rb b/app/controllers/event_disavowal_controller.rb index 35197b15422..90421442a36 100644 --- a/app/controllers/event_disavowal_controller.rb +++ b/app/controllers/event_disavowal_controller.rb @@ -46,7 +46,7 @@ def password_reset_params def validate_disavowed_event result = EventDisavowal::ValidateDisavowedEvent.new(disavowed_event).call return if result.success? - analytics.track_event(Analytics::EVENT_DISAVOWAL_TOKEN_INVALID, result.to_h) + analytics.event_disavowal_token_invalid(**result.to_h) flash[:error] = (result.errors[:event] || result.errors.first.last).first redirect_to root_url end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index a0dd37c3ad5..2a8518c71a4 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -6,7 +6,7 @@ class EventsController < ApplicationController EVENTS_PAGE_SIZE = 25 def show - analytics.track_event(Analytics::EVENTS_VISIT) + analytics.events_visit @presenter = AccountShowPresenter.new( decrypted_pii: nil, personal_key: nil, diff --git a/app/controllers/idv/personal_key_controller.rb b/app/controllers/idv/personal_key_controller.rb index e54dbb05edf..6af05ab18d8 100644 --- a/app/controllers/idv/personal_key_controller.rb +++ b/app/controllers/idv/personal_key_controller.rb @@ -22,20 +22,6 @@ def update redirect_to next_step end - # Remove this after the next deploy - def download - personal_key = user_session[:personal_key] - - analytics.track_event(Analytics::IDV_PERSONAL_KEY_DOWNLOADED, success: personal_key.present?) - - if personal_key.present? - data = personal_key + "\r\n" - send_data data, filename: 'personal_key.txt' - else - head :bad_request - end - end - private def step_indicator_steps diff --git a/app/controllers/redirect/redirect_controller.rb b/app/controllers/redirect/redirect_controller.rb index 5df85350acc..d51f850a695 100644 --- a/app/controllers/redirect/redirect_controller.rb +++ b/app/controllers/redirect/redirect_controller.rb @@ -8,8 +8,13 @@ def location_params params.permit(*PERMITTED_LOCATION_PARAMS).to_h.symbolize_keys end - def redirect_to_and_log(url, event: Analytics::EXTERNAL_REDIRECT) - analytics.track_event(event, redirect_url: url, **location_params) + def redirect_to_and_log(url, event: nil, tracker_method: analytics.method(:external_redirect)) + if event + # Once all events have been moved to tracker methods, we can remove the event: param + analytics.track_event(event, redirect_url: url, **location_params) + else + tracker_method.call(redirect_url: url, **location_params) + end redirect_to(url) end end diff --git a/app/controllers/redirect/return_to_sp_controller.rb b/app/controllers/redirect/return_to_sp_controller.rb index f7a667fe2cc..f9ed68bea8d 100644 --- a/app/controllers/redirect/return_to_sp_controller.rb +++ b/app/controllers/redirect/return_to_sp_controller.rb @@ -9,7 +9,9 @@ def cancel def failure_to_proof redirect_url = sp_return_url_resolver.failure_to_proof_url - redirect_to_and_log redirect_url, event: Analytics::RETURN_TO_SP_FAILURE_TO_PROOF + + analytics.return_to_sp_failure_to_proof(redirect_url: redirect_url, **location_params) + redirect_to(redirect_url) end private diff --git a/app/controllers/risc/security_events_controller.rb b/app/controllers/risc/security_events_controller.rb index 8e6afc4e604..5e0be515ece 100644 --- a/app/controllers/risc/security_events_controller.rb +++ b/app/controllers/risc/security_events_controller.rb @@ -9,7 +9,7 @@ def create form = SecurityEventForm.new(body: request.body.read) result = form.submit - analytics.track_event(Analytics::SECURITY_EVENT_RECEIVED, result.to_h) + analytics.security_event_received(**result.to_h) if result.success? head :accepted diff --git a/app/controllers/users/forget_all_browsers_controller.rb b/app/controllers/users/forget_all_browsers_controller.rb index 544bfa3cfeb..c03396a4faa 100644 --- a/app/controllers/users/forget_all_browsers_controller.rb +++ b/app/controllers/users/forget_all_browsers_controller.rb @@ -3,13 +3,13 @@ class ForgetAllBrowsersController < ApplicationController before_action :confirm_two_factor_authenticated def show - analytics.track_event(Analytics::FORGET_ALL_BROWSERS_VISITED) + analytics.forget_all_browsers_visited end def destroy ForgetAllBrowsers.new(current_user).call - analytics.track_event(Analytics::FORGET_ALL_BROWSERS_SUBMITTED) + analytics.forget_all_browsers_submitted redirect_to account_path end diff --git a/app/controllers/users/rules_of_use_controller.rb b/app/controllers/users/rules_of_use_controller.rb index 3de20ccd1cf..570690f7ab1 100644 --- a/app/controllers/users/rules_of_use_controller.rb +++ b/app/controllers/users/rules_of_use_controller.rb @@ -4,7 +4,7 @@ class RulesOfUseController < ApplicationController before_action :confirm_need_to_accept_rules_of_use def new - analytics.track_event(Analytics::RULES_OF_USE_VISIT) + analytics.rules_of_use_visit @rules_of_use_form = new_rules_of_use_form render :new, formats: :html end @@ -14,7 +14,7 @@ def create result = @rules_of_use_form.submit(permitted_params) - analytics.track_event(Analytics::RULES_OF_USE_SUBMITTED, result.to_h) + analytics.rules_of_use_submitted(**result.to_h) if result.success? process_successful_agreement_to_rules_of_use diff --git a/app/controllers/users/service_provider_revoke_controller.rb b/app/controllers/users/service_provider_revoke_controller.rb index 0be4ef9c891..941637e0c90 100644 --- a/app/controllers/users/service_provider_revoke_controller.rb +++ b/app/controllers/users/service_provider_revoke_controller.rb @@ -9,7 +9,7 @@ class ServiceProviderRevokeController < ApplicationController def show @service_provider = ServiceProvider.find(params[:sp_id]) load_identity!(@service_provider) - analytics.track_event(Analytics::SP_REVOKE_CONSENT_VISITED, issuer: @service_provider.issuer) + analytics.sp_revoke_consent_visited(issuer: @service_provider.issuer) end def destroy @@ -17,7 +17,7 @@ def destroy identity = load_identity!(@service_provider) RevokeServiceProviderConsent.new(identity).call - analytics.track_event(Analytics::SP_REVOKE_CONSENT_REVOKED, issuer: @service_provider.issuer) + analytics.sp_revoke_consent_revoked(issuer: @service_provider.issuer) redirect_to account_connected_accounts_path end diff --git a/app/javascript/app/accessible-forms.js b/app/javascript/app/accessible-forms.js deleted file mode 100644 index fc64a5ac00c..00000000000 --- a/app/javascript/app/accessible-forms.js +++ /dev/null @@ -1,21 +0,0 @@ -// once called the screen reader will not re-read the screen until page re-load -function preventScreenRead() { - const body = document.querySelector('body'); - if (body) { - body.setAttribute('aria-hidden', 'true'); - } -} - -// attaches a submit listener to every form -function accessibleForms() { - // if you do not want aria-hidden added to the body after submit, - // then add the read-after-submit class to the form - const forms = document.querySelectorAll('form:not(.read-after-submit)'); - if (forms) { - [].slice.call(forms).forEach((element) => { - element.addEventListener('submit', preventScreenRead); - }); - } -} - -document.addEventListener('DOMContentLoaded', accessibleForms); diff --git a/app/javascript/app/pw-toggle.js b/app/javascript/app/pw-toggle.js deleted file mode 100644 index 4b46199704f..00000000000 --- a/app/javascript/app/pw-toggle.js +++ /dev/null @@ -1,31 +0,0 @@ -import { t } from '@18f/identity-i18n'; - -function togglePw() { - const inputs = document.querySelectorAll('input.password-toggle[type="password"]'); - - if (inputs) { - [].slice.call(inputs).forEach((input, i) => { - input.parentNode.classList.add('position-relative'); - - const el = ` -
- - -
`; - input.insertAdjacentHTML('afterend', el); - - const toggle = document.getElementById(`pw-toggle-${i}`); - toggle.addEventListener('change', function () { - input.type = toggle.checked ? 'text' : 'password'; - }); - }); - } -} - -document.addEventListener('DOMContentLoaded', togglePw); diff --git a/spec/javascripts/packages/document-capture/components/button-spec.jsx b/app/javascript/packages/components/button.spec.tsx similarity index 92% rename from spec/javascripts/packages/document-capture/components/button-spec.jsx rename to app/javascript/packages/components/button.spec.tsx index 9a17e33e329..8a0ba785a56 100644 --- a/spec/javascripts/packages/document-capture/components/button-spec.jsx +++ b/app/javascript/packages/components/button.spec.tsx @@ -1,13 +1,13 @@ +import { render } from '@testing-library/react'; import sinon from 'sinon'; import userEvent from '@testing-library/user-event'; -import Button from '@18f/identity-document-capture/components/button'; -import { render } from '../../../support/document-capture'; +import Button from './button'; describe('document-capture/components/button', () => { it('renders with default props', () => { const { getByText } = render(); - const button = getByText('Click me'); + const button = getByText('Click me') as HTMLButtonElement; userEvent.click(button); expect(button.nodeName).to.equal('BUTTON'); @@ -88,7 +88,7 @@ describe('document-capture/components/button', () => { , ); - const button = getByText('Click me'); + const button = getByText('Click me') as HTMLButtonElement; userEvent.click(button); expect(onClick.calledOnce).to.be.false(); @@ -103,7 +103,7 @@ describe('document-capture/components/button', () => { , ); - const button = getByText('Click me'); + const button = getByText('Click me') as HTMLButtonElement; expect(button.classList.contains('usa-button--disabled')); expect(button.disabled).to.be.false(); @@ -115,7 +115,7 @@ describe('document-capture/components/button', () => { it('renders with custom type', () => { const { getByText } = render(); - const button = getByText('Click me'); + const button = getByText('Click me') as HTMLButtonElement; expect(button.type).to.equal('submit'); }); diff --git a/app/javascript/packages/components/button.tsx b/app/javascript/packages/components/button.tsx new file mode 100644 index 00000000000..3c6326a3e46 --- /dev/null +++ b/app/javascript/packages/components/button.tsx @@ -0,0 +1,97 @@ +import type { MouseEvent, ReactNode } from 'react'; + +type ButtonType = 'button' | 'reset' | 'submit'; + +export interface ButtonProps { + /** + * Button type, defaulting to "button". + */ + type?: ButtonType; + + /** + * Click handler. + */ + onClick?: (event: MouseEvent) => void; + + /** + * Element children. + */ + children?: ReactNode; + + /** + * Whether button should be styled as big button. + */ + isBig?: boolean; + + /** + * Whether button should be styled as flexible width, such that it shrinks to its minimum width instead of occupying full-width on mobile viewports. + */ + isFlexibleWidth?: boolean; + + /** + * Whether button should be styled as primary button. + */ + isWide?: boolean; + + /** + * Whether button should be styled as secondary button. + */ + isOutline?: boolean; + + /** + * Whether button is disabled. + */ + isDisabled?: boolean; + + /** + * Whether button should be unstyled, visually as a link. + */ + isUnstyled?: boolean; + + /** + * Whether button should appear disabled (but remain clickable). + */ + isVisuallyDisabled?: boolean; + + /** + * Optional additional class names. + */ + className?: string; +} + +function Button({ + type = 'button', + onClick, + children, + isBig, + isFlexibleWidth, + isWide, + isOutline, + isDisabled, + isUnstyled, + isVisuallyDisabled, + className, +}: ButtonProps) { + const classes = [ + 'usa-button', + isBig && 'usa-button--big', + isFlexibleWidth && 'usa-button--flexible-width', + isWide && 'usa-button--wide', + isOutline && 'usa-button--outline', + isUnstyled && 'usa-button--unstyled', + isVisuallyDisabled && 'usa-button--disabled', + className, + ] + .filter(Boolean) + .join(' '); + + return ( + // Disable reason: We can assume `type` is provided as valid, or the default `button`. + // eslint-disable-next-line react/button-has-type + + ); +} + +export default Button; diff --git a/app/javascript/packages/components/index.js b/app/javascript/packages/components/index.js index 3adf7cf4fab..b38692beead 100644 --- a/app/javascript/packages/components/index.js +++ b/app/javascript/packages/components/index.js @@ -1,4 +1,5 @@ export { default as Alert } from './alert'; +export { default as Button } from './button'; export { default as BlockLink } from './block-link'; export { default as Icon } from './icon'; export { default as SpinnerDots } from './spinner-dots'; diff --git a/app/javascript/packages/decorators/index.ts b/app/javascript/packages/decorators/index.ts new file mode 100644 index 00000000000..e784b1ac9d3 --- /dev/null +++ b/app/javascript/packages/decorators/index.ts @@ -0,0 +1 @@ +export { once } from './once'; diff --git a/app/javascript/packages/decorators/once.spec.ts b/app/javascript/packages/decorators/once.spec.ts new file mode 100644 index 00000000000..2a5caf55141 --- /dev/null +++ b/app/javascript/packages/decorators/once.spec.ts @@ -0,0 +1,106 @@ +import sinon from 'sinon'; +import { once } from './once'; + +describe('once', () => { + let spy: sinon.SinonSpy; + + beforeEach(() => { + spy = sinon.spy(); + }); + + context('getter', () => { + class Example { + expectedResult?: any; + + constructor(expectedResult?: any) { + this.expectedResult = expectedResult; + } + + @once() + get foo() { + spy(); + return this.expectedResult; + } + } + + it('returns the value of the original function', () => { + const example = new Example(); + const result = example.foo; + + expect(result).to.equal(example.expectedResult); + }); + + it('returns the value of the original function, once', () => { + const example = new Example(); + const result1 = example.foo; + const result2 = example.foo; + + expect(result1).to.equal(example.expectedResult); + expect(result2).to.equal(example.expectedResult); + expect(spy).to.have.been.calledOnce(); + }); + + it('returns the value of the original function, once per instance', () => { + const example1 = new Example(1); + const result1 = example1.foo; + const result2 = example1.foo; + const example2 = new Example(2); + const result3 = example2.foo; + const result4 = example2.foo; + + expect(result1).to.equal(example1.expectedResult); + expect(result2).to.equal(example1.expectedResult); + expect(result3).to.equal(example2.expectedResult); + expect(result4).to.equal(example2.expectedResult); + expect(spy).to.have.been.calledTwice(); + }); + }); + + context('function', () => { + class Example { + expectedResult?: any; + + constructor(expectedResult?: any) { + this.expectedResult = expectedResult; + } + + @once() + getFoo() { + spy(); + return this.expectedResult; + } + } + + it('returns the value of the original function', () => { + const example = new Example(); + const result = example.getFoo(); + + expect(result).to.equal(example.expectedResult); + }); + + it('returns the value of the original function, once', () => { + const example = new Example(); + const result1 = example.getFoo(); + const result2 = example.getFoo(); + + expect(result1).to.equal(example.expectedResult); + expect(result2).to.equal(example.expectedResult); + expect(spy).to.have.been.calledOnce(); + }); + + it('returns the value of the original function, once per instance', () => { + const example1 = new Example(1); + const result1 = example1.getFoo(); + const result2 = example1.getFoo(); + const example2 = new Example(2); + const result3 = example2.getFoo(); + const result4 = example2.getFoo(); + + expect(result1).to.equal(example1.expectedResult); + expect(result2).to.equal(example1.expectedResult); + expect(result3).to.equal(example2.expectedResult); + expect(result4).to.equal(example2.expectedResult); + expect(spy).to.have.been.calledTwice(); + }); + }); +}); diff --git a/app/javascript/packages/decorators/once.ts b/app/javascript/packages/decorators/once.ts new file mode 100644 index 00000000000..a5fee066961 --- /dev/null +++ b/app/javascript/packages/decorators/once.ts @@ -0,0 +1,20 @@ +export const once = + (): MethodDecorator => + (_target, propertyKey, descriptor) => { + const { value, get } = descriptor; + if (typeof value === 'function') { + descriptor.value = function () { + const result = value.call(this) as T; + Object.defineProperty(this, propertyKey, { value: () => result }); + return result; + }; + } else if (get) { + descriptor.get = function () { + const result = get.call(this) as T; + Object.defineProperty(this, propertyKey, { value: result }); + return result; + }; + } + + return descriptor; + }; diff --git a/app/javascript/packages/decorators/package.json b/app/javascript/packages/decorators/package.json new file mode 100644 index 00000000000..a9d64aefc66 --- /dev/null +++ b/app/javascript/packages/decorators/package.json @@ -0,0 +1,5 @@ +{ + "name": "@18f/identity-decorators", + "private": true, + "version": "1.0.0" +} diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index 4914b3a4357..1760593197b 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -8,6 +8,8 @@ import { useImperativeHandle, } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; +import { useIfStillMounted, useDidUpdateEffect } from '@18f/identity-react-hooks'; +import { Button } from '@18f/identity-components'; import AnalyticsContext from '../context/analytics'; import AcuantContext from '../context/acuant'; import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; @@ -15,11 +17,8 @@ import AcuantCamera from './acuant-camera'; import AcuantCaptureCanvas from './acuant-capture-canvas'; import FileInput from './file-input'; import FullScreen from './full-screen'; -import Button from './button'; import DeviceContext from '../context/device'; import UploadContext from '../context/upload'; -import useIfStillMounted from '../hooks/use-if-still-mounted'; -import useDidUpdateEffect from '../hooks/use-did-update-effect'; import useCounter from '../hooks/use-counter'; import useCookie from '../hooks/use-cookie'; diff --git a/app/javascript/packages/document-capture/components/button-to.jsx b/app/javascript/packages/document-capture/components/button-to.jsx index 20aaf3af332..ece99c6a296 100644 --- a/app/javascript/packages/document-capture/components/button-to.jsx +++ b/app/javascript/packages/document-capture/components/button-to.jsx @@ -1,9 +1,9 @@ import { useContext, useRef } from 'react'; import { createPortal } from 'react-dom'; +import { Button } from '@18f/identity-components'; import UploadContext from '../context/upload'; -import Button from './button'; -/** @typedef {import('./button').ButtonProps} ButtonProps */ +/** @typedef {import('@18f/identity-components/button').ButtonProps} ButtonProps */ /** * @typedef NativeButtonToProps diff --git a/app/javascript/packages/document-capture/components/button.jsx b/app/javascript/packages/document-capture/components/button.jsx deleted file mode 100644 index 26f11c6359a..00000000000 --- a/app/javascript/packages/document-capture/components/button.jsx +++ /dev/null @@ -1,60 +0,0 @@ -/** @typedef {import('react').MouseEvent} ReactMouseEvent */ -/** @typedef {import('react').ReactNode} ReactNode */ -/** @typedef {"button"|"reset"|"submit"} ButtonType */ - -/** - * @typedef ButtonProps - * - * @prop {ButtonType=} type Button type, defaulting to "button". - * @prop {(ReactMouseEvent)=>void=} onClick Click handler. - * @prop {ReactNode=} children Element children. - * @prop {boolean=} isBig Whether button should be styled as big button. - * @prop {boolean=} isFlexibleWidth Whether button should be styled as flexible width, such that it - * shrinks to its minimum width instead of occupying full-width on mobile viewports. - * @prop {boolean=} isWide Whether button should be styled as primary button. - * @prop {boolean=} isOutline Whether button should be styled as secondary button. - * @prop {boolean=} isDisabled Whether button is disabled. - * @prop {boolean=} isUnstyled Whether button should be unstyled, visually as a link. - * @prop {boolean=} isVisuallyDisabled Whether button should appear disabled (but remain clickable). - * @prop {string=} className Optional additional class names. - */ - -/** - * @param {ButtonProps} props Props object. - */ -function Button({ - type = 'button', - onClick, - children, - isBig, - isFlexibleWidth, - isWide, - isOutline, - isDisabled, - isUnstyled, - isVisuallyDisabled, - className, -}) { - const classes = [ - 'usa-button', - isBig && 'usa-button--big', - isFlexibleWidth && 'usa-button--flexible-width', - isWide && 'usa-button--wide', - isOutline && 'usa-button--outline', - isUnstyled && 'usa-button--unstyled', - isVisuallyDisabled && 'usa-button--disabled', - className, - ] - .filter(Boolean) - .join(' '); - - return ( - // Disable reason: We can assume `type` is provided as valid, or the default `button`. - // eslint-disable-next-line react/button-has-type - - ); -} - -export default Button; diff --git a/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx index ea25445c511..c0eb2aded7d 100644 --- a/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx +++ b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx @@ -1,10 +1,10 @@ import { useContext, useState } from 'react'; +import { useDidUpdateEffect } from '@18f/identity-react-hooks'; import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; import AnalyticsContext from '../context/analytics'; import CallbackOnMount from './callback-on-mount'; import CaptureAdvice from './capture-advice'; import { FormStepsContext } from './form-steps'; -import useDidUpdateEffect from '../hooks/use-did-update-effect'; /** @typedef {import('react').ReactNode} ReactNode */ diff --git a/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx b/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx index 6912a9afed6..36ee4131f55 100644 --- a/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx @@ -1,6 +1,6 @@ -import { useI18n } from '@18f/identity-react-i18n'; +import { t } from '@18f/identity-i18n'; import AcuantCapture from './acuant-capture'; -import FormErrorMessage, { CameraAccessDeclinedError } from './form-error-message'; +import { FormError } from './form-steps'; /** @typedef {import('./form-steps').FormStepError<*>} FormStepError */ /** @typedef {import('./form-steps').RegisterFieldCallback} RegisterFieldCallback */ @@ -19,6 +19,17 @@ import FormErrorMessage, { CameraAccessDeclinedError } from './form-error-messag * @prop {string=} className */ +/** + * An error representing user declined access to camera. + */ +export class CameraAccessDeclinedError extends FormError { + get message() { + return this.isDetail + ? t('doc_auth.errors.camera.blocked_detail') + : t('doc_auth.errors.camera.blocked'); + } +} + /** * @param {DocumentSideAcuantCaptureProps} props Props object. */ @@ -31,7 +42,6 @@ function DocumentSideAcuantCapture({ onError, className, }) { - const { t } = useI18n(); const error = errors.find(({ field }) => field === side)?.error; return ( @@ -52,9 +62,9 @@ function DocumentSideAcuantCapture({ } onCameraAccessDeclined={() => { onError(new CameraAccessDeclinedError(), { field: side }); - onError(new CameraAccessDeclinedError()); + onError(new CameraAccessDeclinedError({ isDetail: true })); }} - errorMessage={error ? : undefined} + errorMessage={error ? error.message : undefined} name={side} className={className} capture="environment" diff --git a/app/javascript/packages/document-capture/components/file-image.jsx b/app/javascript/packages/document-capture/components/file-image.jsx index d4f9db5ac20..f7ceb43618f 100644 --- a/app/javascript/packages/document-capture/components/file-image.jsx +++ b/app/javascript/packages/document-capture/components/file-image.jsx @@ -1,5 +1,5 @@ import { useContext, useState, useEffect } from 'react'; -import useIfStillMounted from '../hooks/use-if-still-mounted'; +import { useIfStillMounted } from '@18f/identity-react-hooks'; import FileBase64CacheContext from '../context/file-base64-cache'; /** diff --git a/app/javascript/packages/document-capture/components/form-error-message.jsx b/app/javascript/packages/document-capture/components/form-error-message.jsx index 96acec110dc..e69de29bb2d 100644 --- a/app/javascript/packages/document-capture/components/form-error-message.jsx +++ b/app/javascript/packages/document-capture/components/form-error-message.jsx @@ -1,68 +0,0 @@ -import { useI18n } from '@18f/identity-react-i18n'; -import { UploadFormEntryError } from '../services/upload'; -import { BackgroundEncryptedUploadError } from '../higher-order/with-background-encrypted-upload'; - -/** @typedef {import('react').ReactNode} ReactNode */ - -/** - * @typedef FormErrorMessageProps - * - * @prop {Error} error Error for which message should be generated. - * @prop {boolean=} isDetail Whether to use an extended description for the error, if available. - */ - -/** - * Non-breaking space (` `) represented as unicode escape sequence, which React will more - * happily tolerate than an HTML entity. - * - * @type {string} - */ -const NBSP_UNICODE = '\u00A0'; - -/** - * An error representing a state where a required form value is missing. - */ -export class RequiredValueMissingError extends Error {} - -/** - * An error representing user declined access to camera. - */ -export class CameraAccessDeclinedError extends Error {} - -/** - * @param {FormErrorMessageProps} props Props object. - */ -function FormErrorMessage({ error, isDetail = false }) { - const { t } = useI18n(); - - if (error instanceof RequiredValueMissingError) { - return <>{t('simple_form.required.text')}; - } - - if (error instanceof CameraAccessDeclinedError) { - return ( - <> - {isDetail - ? t('doc_auth.errors.camera.blocked_detail') - : t('doc_auth.errors.camera.blocked')} - - ); - } - - if (error instanceof UploadFormEntryError) { - return <>{error.message}; - } - - if (error instanceof BackgroundEncryptedUploadError) { - return ( - <> - {t('doc_auth.errors.upload_error')}{' '} - {t('errors.messages.try_again').split(' ').join(NBSP_UNICODE)} - - ); - } - - return null; -} - -export default FormErrorMessage; diff --git a/app/javascript/packages/document-capture/components/form-steps.jsx b/app/javascript/packages/document-capture/components/form-steps.tsx similarity index 60% rename from app/javascript/packages/document-capture/components/form-steps.jsx rename to app/javascript/packages/document-capture/components/form-steps.tsx index 835842fec25..996fef91624 100644 --- a/app/javascript/packages/document-capture/components/form-steps.jsx +++ b/app/javascript/packages/document-capture/components/form-steps.tsx @@ -1,119 +1,199 @@ import { useEffect, useRef, useState, createContext, useContext } from 'react'; -import { useI18n } from '@18f/identity-react-i18n'; -import { Alert } from '@18f/identity-components'; -import Button from './button'; -import FormErrorMessage, { RequiredValueMissingError } from './form-error-message'; +import type { RefCallback, FormEventHandler, FC } from 'react'; +import { t } from '@18f/identity-i18n'; +import { Alert, Button } from '@18f/identity-components'; +import { useDidUpdateEffect, useIfStillMounted } from '@18f/identity-react-hooks'; import PromptOnNavigate from './prompt-on-navigate'; import useHistoryParam from '../hooks/use-history-param'; import useForceRender from '../hooks/use-force-render'; -import useDidUpdateEffect from '../hooks/use-did-update-effect'; -import useIfStillMounted from '../hooks/use-if-still-mounted'; -/** - * @typedef FormStepError - * - * @prop {keyof V} field Name of field for which error occurred. - * @prop {Error} error Error object. - * - * @template V - */ +export class FormError extends Error { + isDetail: boolean; -/** - * @typedef FormStepRegisterFieldOptions - * - * @prop {boolean} isRequired Whether field is required. - */ + constructor(options?: { isDetail: boolean }) { + super(); -/** - * @typedef {( - * field:string, - * options?:Partial - * )=>undefined|import('react').RefCallback} RegisterFieldCallback - */ + this.isDetail = Boolean(options?.isDetail); + } +} /** - * @typedef {(error:Error, options?: {field?: string?})=>void} OnErrorCallback + * An error representing a state where a required form value is missing. */ +export class RequiredValueMissingError extends FormError { + message = t('simple_form.required.text'); +} -/** - * @typedef FormStepComponentProps - * - * @prop {(nextValues:Partial)=>void} onChange Update values, merging with existing values. - * @prop {OnErrorCallback} onError Trigger a field error. - * @prop {Partial} value Current values. - * @prop {FormStepError[]} errors Current active errors. - * @prop {FormStepError[]} unknownFieldErrors Current top-level errors. - * @prop {RegisterFieldCallback} registerField Registers field by given name, returning ref - * assignment function. - * - * @template V - */ +export interface FormStepError { + /** + * Name of field for which error occurred. + */ + field: keyof V; -/** - * @typedef FormStep - * - * @prop {string} name Step name, used in history parameter. - * @prop {import('react').FC>>} form Step form component. - * @prop {(object)=>boolean=} validator Optional function to validate values for the step - */ + /** + * Error object. + */ + error: Error; +} -/** - * @typedef FieldsRefEntry - * - * @prop {import('react').RefCallback} refCallback Ref callback. - * @prop {boolean} isRequired Whether field is required. - * @prop {HTMLElement?} element Element assigned by ref callback. - */ +interface FormStepRegisterFieldOptions { + /** + * Whether field is required. + */ + isRequired: boolean; +} -/** - * @typedef FormStepsProps - * - * @prop {FormStep[]=} steps Form steps. - * @prop {Record=} initialValues Form values to populate initial state. - * @prop {FormStepError>[]=} initialActiveErrors Errors to initialize state. - * @prop {boolean=} autoFocus Whether to automatically focus heading on mount. - * @prop {(values:Record)=>void=} onComplete Form completion callback. - * @prop {()=>void=} onStepChange Callback triggered on step change. - */ +export type RegisterFieldCallback = ( + field: string, + options?: Partial, +) => undefined | RefCallback; -/** - * @typedef FormStepsContext - * - * @prop {boolean} isLastStep Whether the current step is the last step in the flow. - * @prop {boolean} canContinueToNextStep Whether the user can proceed to the next step. - * @prop {() => void} onPageTransition Callback invoked when content is reset in a page transition. - */ +export type OnErrorCallback = (error: Error, options?: { field?: string | null }) => void; + +export interface FormStepComponentProps { + /** + * Update values, merging with existing values. + */ + onChange: (nextValues: Partial) => void; + + /** + * Trigger a field error. + */ + onError: OnErrorCallback; + + /** + * Current values. + */ + value: Partial; + + /** + * Current active errors. + */ + errors: FormStepError[]; + + /** + * Current top-level errors. + */ + unknownFieldErrors: FormStepError[]; + + /** + * Registers field by given name, returning ref assignment function. + */ + registerField: RegisterFieldCallback; +} + +export interface FormStep { + /** + * Step name, used in history parameter. + */ + name: string; + + /** + * Step form component. + */ + form: FC>>; + + /** + * Optional function to validate values for the step + */ + validator?: (object) => boolean; +} + +interface FieldsRefEntry { + /** + * Ref callback. + */ + refCallback: RefCallback; + + /** + * Whether field is required. + */ + isRequired: boolean; + + /** + * Element assigned by ref callback. + */ + element: HTMLElement | null; +} + +interface FormStepsProps { + /** + * Form steps. + */ + steps?: FormStep[]; + + /** + * Form values to populate initial state. + */ + initialValues?: Record; -export const FormStepsContext = createContext( - /** @type {FormStepsContext} */ ({ - isLastStep: true, - canContinueToNextStep: true, - onPageTransition: () => {}, - }), -); + /** + * Errors to initialize state. + */ + initialActiveErrors?: FormStepError>[]; + + /** + * Whether to automatically focus heading on mount. + */ + autoFocus?: boolean; + + /** + * Form completion callback. + */ + onComplete?: (values: Record) => void; + + /** + * Callback triggered on step change. + */ + onStepChange?: () => void; +} + +interface FormStepsContextValue { + /** + * Whether the current step is the last step in the flow. + */ + isLastStep: boolean; + + /** + * Whether the user can proceed to the next step. + */ + canContinueToNextStep: boolean; + + /** + * Callback invoked when content is reset in a page transition. + */ + onPageTransition: () => void; +} + +export const FormStepsContext = createContext({ + isLastStep: true, + canContinueToNextStep: true, + onPageTransition: () => {}, +} as FormStepsContextValue); /** * Returns the index of the step in the array which matches the given name. Returns `-1` if there is * no step found by that name. * - * @param {FormStep[]} steps Form steps. - * @param {string} name Step to search. + * @param steps Form steps. + * @param name Step to search. * - * @return {number} Step index. + * @return Step index. */ -export function getStepIndexByName(steps, name) { +export function getStepIndexByName(steps: FormStep[], name: string) { return steps.findIndex((step) => step.name === name); } /** * Returns the first element matched to a field from a set of errors, if exists. * - * @param {FormStepError>[]} errors Active form step errors. - * @param {Record} fields Current fields. - * - * @return {HTMLElement=} + * @param errors Active form step errors. + * @param fields Current fields. */ -function getFieldActiveErrorFieldElement(errors, fields) { +function getFieldActiveErrorFieldElement( + errors: FormStepError>[], + fields: Record, +) { const error = errors.find(({ field }) => fields[field]?.element); if (error) { @@ -121,9 +201,6 @@ function getFieldActiveErrorFieldElement(errors, fields) { } } -/** - * @param {FormStepsProps} props Props object. - */ function FormSteps({ steps = [], onComplete = () => {}, @@ -131,13 +208,13 @@ function FormSteps({ initialValues = {}, initialActiveErrors = [], autoFocus, -}) { +}: FormStepsProps) { const [values, setValues] = useState(initialValues); const [activeErrors, setActiveErrors] = useState(initialActiveErrors); - const formRef = useRef(/** @type {?HTMLFormElement} */ (null)); + const formRef = useRef(null as HTMLFormElement | null); const [stepName, setStepName] = useHistoryParam('step', null); - const [stepErrors, setStepErrors] = useState(/** @type {Error[]} */ ([])); - const fields = useRef(/** @type {Record} */ ({})); + const [stepErrors, setStepErrors] = useState([] as Error[]); + const fields = useRef({} as Record); const didSubmitWithErrors = useRef(false); const forceRender = useForceRender(); const ifStillMounted = useIfStillMounted(); @@ -184,10 +261,8 @@ function FormSteps({ /** * Returns array of form errors for the current set of values. - * - * @return {FormStepError>[]} */ - function getValidationErrors() { + function getValidationErrors(): FormStepError>[] { return Object.keys(fields.current).reduce((result, key) => { const { element, isRequired } = fields.current[key]; const isActive = !!element; @@ -197,7 +272,7 @@ function FormSteps({ } return result; - }, /** @type {FormStepError>[]} */ ([])); + }, [] as FormStepError>[]); } // An empty steps array is allowed, in which case there is nothing to render. @@ -214,10 +289,8 @@ function FormSteps({ /** * Increments state to the next step, or calls onComplete callback if the current step is the last * step. - * - * @type {import('react').FormEventHandler} */ - function toNextStep(event) { + const toNextStep: FormEventHandler = (event) => { event.preventDefault(); // Don't proceed if field errors have yet to be resolved. @@ -244,17 +317,17 @@ function FormSteps({ const { name: nextStepName } = steps[nextStepIndex]; setStepName(nextStepName); } - } + }; const { form: Form, name } = step; const isLastStep = stepIndex + 1 === steps.length; return ( -
+ {Object.keys(values).length > 0 && } {stepErrors.map((error) => ( - + {error.message} ))} @@ -301,7 +374,6 @@ function FormSteps({ export function FormStepsContinueButton() { const { canContinueToNextStep, isLastStep } = useContext(FormStepsContext); - const { t } = useI18n(); return ( , document.body, diff --git a/app/javascript/packages/document-capture/components/full-screen.scss b/app/javascript/packages/document-capture/components/full-screen.scss new file mode 100644 index 00000000000..bac859c1c90 --- /dev/null +++ b/app/javascript/packages/document-capture/components/full-screen.scss @@ -0,0 +1,32 @@ +.has-full-screen-overlay { + overflow: hidden; +} + +.full-screen { + bottom: 0; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 1000; +} + +.full-screen__close-button { + background-color: rgba(color('base-darkest'), 0.7); + border-radius: 0; + position: absolute; + right: 0; + top: 0; + width: auto; + z-index: 10; + + &:hover, + &:focus { + background-color: color('base-darker'); + } +} + +.full-screen__close-icon { + height: 1rem; + width: 1rem; +} diff --git a/app/javascript/packages/document-capture/components/review-issues-step.jsx b/app/javascript/packages/document-capture/components/review-issues-step.jsx index d8571e0cfe5..9725fcedd15 100644 --- a/app/javascript/packages/document-capture/components/review-issues-step.jsx +++ b/app/javascript/packages/document-capture/components/review-issues-step.jsx @@ -1,12 +1,12 @@ import { useContext, useState } from 'react'; import { hasMediaAccess } from '@18f/identity-device'; import { useI18n } from '@18f/identity-react-i18n'; +import { useDidUpdateEffect } from '@18f/identity-react-hooks'; import { FormStepsContext, FormStepsContinueButton } from './form-steps'; import DeviceContext from '../context/device'; import DocumentSideAcuantCapture from './document-side-acuant-capture'; import AcuantCapture from './acuant-capture'; import SelfieCapture from './selfie-capture'; -import FormErrorMessage from './form-error-message'; import ServiceProviderContext from '../context/service-provider'; import withBackgroundEncryptedUpload from '../higher-order/with-background-encrypted-upload'; import DocumentCaptureTroubleshootingOptions from './document-capture-troubleshooting-options'; @@ -14,7 +14,6 @@ import PageHeading from './page-heading'; import StartOverOrCancel from './start-over-or-cancel'; import Warning from './warning'; import AnalyticsContext from '../context/analytics'; -import useDidUpdateEffect from '../hooks/use-did-update-effect'; /** * @typedef {'front'|'back'} DocumentSide @@ -133,7 +132,7 @@ function ReviewIssuesStep({ onChange={(nextSelfie) => onChange({ selfie: nextSelfie })} allowUpload={false} className="document-capture-review-issues-step__input" - errorMessage={selfieError ? : undefined} + errorMessage={selfieError?.message} name="selfie" /> ) : ( @@ -141,7 +140,7 @@ function ReviewIssuesStep({ ref={registerField('selfie', { isRequired: true })} value={value.selfie} onChange={(nextSelfie) => onChange({ selfie: nextSelfie })} - errorMessage={selfieError ? : undefined} + errorMessage={selfieError?.message} className={[ 'document-capture-review-issues-step__input', !value.selfie && 'document-capture-review-issues-step__input--unconstrained-width', diff --git a/app/javascript/packages/document-capture/components/selfie-capture.jsx b/app/javascript/packages/document-capture/components/selfie-capture.jsx index 7ab49404bb3..89d581786b9 100644 --- a/app/javascript/packages/document-capture/components/selfie-capture.jsx +++ b/app/javascript/packages/document-capture/components/selfie-capture.jsx @@ -10,8 +10,8 @@ import { } from 'react'; import { Icon } from '@18f/identity-components'; import { useI18n } from '@18f/identity-react-i18n'; +import { useIfStillMounted } from '@18f/identity-react-hooks'; import FileImage from './file-image'; -import useIfStillMounted from '../hooks/use-if-still-mounted'; import useInstanceId from '../hooks/use-instance-id'; import useFocusFallbackRef from '../hooks/use-focus-fallback-ref'; import AppContext from '../context/app'; diff --git a/app/javascript/packages/document-capture/components/selfie-step.jsx b/app/javascript/packages/document-capture/components/selfie-step.jsx index 46624213f85..dca980b92be 100644 --- a/app/javascript/packages/document-capture/components/selfie-step.jsx +++ b/app/javascript/packages/document-capture/components/selfie-step.jsx @@ -5,7 +5,6 @@ import { FormStepsContinueButton } from './form-steps'; import DeviceContext from '../context/device'; import AcuantCapture from './acuant-capture'; import SelfieCapture from './selfie-capture'; -import FormErrorMessage from './form-error-message'; import withBackgroundEncryptedUpload from '../higher-order/with-background-encrypted-upload'; import PageHeading from './page-heading'; import StartOverOrCancel from './start-over-or-cancel'; @@ -55,7 +54,7 @@ function SelfieStep({ value={value.selfie} onChange={(nextSelfie) => onChange({ selfie: nextSelfie })} allowUpload={false} - errorMessage={error ? : undefined} + errorMessage={error?.message} name="selfie" /> ) : ( @@ -63,7 +62,7 @@ function SelfieStep({ ref={registerField('selfie', { isRequired: true })} value={value.selfie} onChange={(nextSelfie) => onChange({ selfie: nextSelfie })} - errorMessage={error ? : undefined} + errorMessage={error?.message} /> )} diff --git a/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx b/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx index 0b3fb72873d..dfc7b8bf720 100644 --- a/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx +++ b/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx @@ -1,20 +1,39 @@ import { useContext } from 'react'; +import { t } from '@18f/identity-i18n'; import UploadContext from '../context/upload'; import AnalyticsContext from '../context/analytics'; +import { FormError } from '../components/form-steps'; /** * @typedef {import('../components/form-steps').FormStepComponentProps} FormStepComponentProps * @template V */ +/** + * Non-breaking space (` `) represented as unicode escape sequence, which React will more + * happily tolerate than an HTML entity. + */ +const NBSP_UNICODE = '\u00A0'; + +/** + * Returns a new string from the given string, replacing spaces with non-breaking spaces. + * + * @param {string} string Original string. + * + * @return String with non-breaking spaces. + */ +const nonBreaking = (string) => string.split(' ').join(NBSP_UNICODE); + /** * An error representing a failure to complete encrypted upload of image. */ -export class BackgroundEncryptedUploadError extends Error { +export class BackgroundEncryptedUploadError extends FormError { baseField = ''; /** @type {string[]} */ fields = []; + + message = `${t('doc_auth.errors.upload_error')} ${nonBreaking(t('errors.messages.try_again'))}`; } /** diff --git a/app/javascript/packages/document-capture/services/upload.js b/app/javascript/packages/document-capture/services/upload.js index 22f4ea083d1..2303be184ce 100644 --- a/app/javascript/packages/document-capture/services/upload.js +++ b/app/javascript/packages/document-capture/services/upload.js @@ -1,13 +1,24 @@ +import { FormError } from '../components/form-steps'; + /** @typedef {import('../context/upload').UploadSuccessResponse} UploadSuccessResponse */ /** @typedef {import('../context/upload').UploadErrorResponse} UploadErrorResponse */ /** @typedef {import('../context/upload').UploadFieldError} UploadFieldError */ -export class UploadFormEntryError extends Error { +export class UploadFormEntryError extends FormError { /** @type {string} */ field = ''; + + /** + * @param {string} message + */ + constructor(message) { + super(); + + this.message = message; + } } -export class UploadFormEntriesError extends Error { +export class UploadFormEntriesError extends FormError { /** @type {UploadFormEntryError[]} */ formEntryErrors = []; diff --git a/app/javascript/packages/document-capture/styles.scss b/app/javascript/packages/document-capture/styles.scss index aa63ce7562f..f2a0e4d3c83 100644 --- a/app/javascript/packages/document-capture/styles.scss +++ b/app/javascript/packages/document-capture/styles.scss @@ -2,5 +2,6 @@ @import './components/acuant-capture'; @import './components/acuant-capture-canvas'; @import './components/form-steps'; +@import './components/full-screen'; @import './components/review-issues-step'; @import './components/selfie-capture'; diff --git a/app/javascript/packages/password-toggle-element/index.spec.ts b/app/javascript/packages/password-toggle-element/index.spec.ts new file mode 100644 index 00000000000..364869491c9 --- /dev/null +++ b/app/javascript/packages/password-toggle-element/index.spec.ts @@ -0,0 +1,53 @@ +import userEvent from '@testing-library/user-event'; +import { getByLabelText } from '@testing-library/dom'; +import { PasswordToggleElement } from './index'; + +describe('PasswordToggleElement', () => { + let idCounter = 0; + + before(() => { + if (!customElements.get('lg-password-toggle')) { + customElements.define('lg-password-toggle', PasswordToggleElement); + } + }); + + function createElement() { + const element = document.createElement('lg-password-toggle') as PasswordToggleElement; + const idSuffix = ++idCounter; + element.innerHTML = ` + + +
+ + +
`; + document.body.appendChild(element); + return element; + } + + it('initializes input type', () => { + const element = createElement(); + + const input = getByLabelText(element, 'Password') as HTMLInputElement; + + expect(input.type).to.equal('password'); + }); + + it('changes input type on toggle', () => { + const element = createElement(); + + const input = getByLabelText(element, 'Password') as HTMLInputElement; + const toggle = getByLabelText(element, 'Show password') as HTMLInputElement; + + userEvent.click(toggle); + + expect(input.type).to.equal('text'); + }); +}); diff --git a/app/javascript/packages/password-toggle-element/index.ts b/app/javascript/packages/password-toggle-element/index.ts new file mode 100644 index 00000000000..c66933fbaed --- /dev/null +++ b/app/javascript/packages/password-toggle-element/index.ts @@ -0,0 +1,32 @@ +import { once } from '@18f/identity-decorators'; + +interface PasswordToggleElements { + /** + * Checkbox toggle for visibility. + */ + toggle: HTMLInputElement; + + /** + * Text or password input. + */ + input: HTMLInputElement; +} + +export class PasswordToggleElement extends HTMLElement { + connectedCallback() { + this.elements.toggle.addEventListener('change', () => this.setInputType()); + this.setInputType(); + } + + @once() + get elements(): PasswordToggleElements { + return { + toggle: this.querySelector('.password-toggle__toggle')!, + input: this.querySelector('.password-toggle__input')!, + }; + } + + setInputType() { + this.elements.input.type = this.elements.toggle.checked ? 'text' : 'password'; + } +} diff --git a/app/javascript/packages/password-toggle-element/package.json b/app/javascript/packages/password-toggle-element/package.json new file mode 100644 index 00000000000..18528af20db --- /dev/null +++ b/app/javascript/packages/password-toggle-element/package.json @@ -0,0 +1,5 @@ +{ + "name": "@18f/identity-password-toggle-element", + "private": true, + "version": "1.0.0" +} diff --git a/app/javascript/packages/react-hooks/README.md b/app/javascript/packages/react-hooks/README.md new file mode 100644 index 00000000000..9e9baa458b1 --- /dev/null +++ b/app/javascript/packages/react-hooks/README.md @@ -0,0 +1,3 @@ +# `@18f/identity-react-hooks` + +A collection of general-purpose [React hooks](https://reactjs.org/docs/hooks-intro.html). diff --git a/app/javascript/packages/react-hooks/index.ts b/app/javascript/packages/react-hooks/index.ts new file mode 100644 index 00000000000..92a82a73d2a --- /dev/null +++ b/app/javascript/packages/react-hooks/index.ts @@ -0,0 +1,2 @@ +export { default as useDidUpdateEffect } from './use-did-update-effect'; +export { default as useIfStillMounted } from './use-if-still-mounted'; diff --git a/app/javascript/packages/react-hooks/package.json b/app/javascript/packages/react-hooks/package.json new file mode 100644 index 00000000000..0c7f4ca36a9 --- /dev/null +++ b/app/javascript/packages/react-hooks/package.json @@ -0,0 +1,8 @@ +{ + "name": "@18f/identity-react-hooks", + "private": true, + "version": "1.0.0", + "peerDependencies": { + "react": ">=16.8.0" + } +} diff --git a/spec/javascripts/packages/document-capture/hooks/use-did-update-effect-spec.jsx b/app/javascript/packages/react-hooks/use-did-update-effect.spec.tsx similarity index 94% rename from spec/javascripts/packages/document-capture/hooks/use-did-update-effect-spec.jsx rename to app/javascript/packages/react-hooks/use-did-update-effect.spec.tsx index 84bd41e05bb..060c8f0b496 100644 --- a/spec/javascripts/packages/document-capture/hooks/use-did-update-effect-spec.jsx +++ b/app/javascript/packages/react-hooks/use-did-update-effect.spec.tsx @@ -1,6 +1,6 @@ import sinon from 'sinon'; import { renderHook } from '@testing-library/react-hooks'; -import useDidUpdateEffect from '@18f/identity-document-capture/hooks/use-did-update-effect'; +import useDidUpdateEffect from './use-did-update-effect'; describe('document-capture/hooks/use-did-update-effect', () => { context('no dependencies', () => { diff --git a/app/javascript/packages/document-capture/hooks/use-did-update-effect.js b/app/javascript/packages/react-hooks/use-did-update-effect.ts similarity index 86% rename from app/javascript/packages/document-capture/hooks/use-did-update-effect.js rename to app/javascript/packages/react-hooks/use-did-update-effect.ts index 1cffffd2eca..4db18b41615 100644 --- a/app/javascript/packages/document-capture/hooks/use-did-update-effect.js +++ b/app/javascript/packages/react-hooks/use-did-update-effect.ts @@ -4,10 +4,8 @@ import { useRef, useEffect } from 'react'; * A hook behaving the same as useEffect in invoking the given callback when dependencies change, * but does not call the callback during initial mount or when unmounting. It can be considered as * similar to ReactComponent#componentDidUpdate. - * - * @type {typeof useEffect} */ -function useDidUpdateEffect(callback, deps) { +const useDidUpdateEffect: typeof useEffect = (callback, deps) => { const isMounting = useRef(true); useEffect(() => { @@ -17,6 +15,6 @@ function useDidUpdateEffect(callback, deps) { callback(); } }, deps); -} +}; export default useDidUpdateEffect; diff --git a/spec/javascripts/packages/document-capture/hooks/use-if-still-mounted-spec.jsx b/app/javascript/packages/react-hooks/use-if-still-mounted.spec.tsx similarity index 89% rename from spec/javascripts/packages/document-capture/hooks/use-if-still-mounted-spec.jsx rename to app/javascript/packages/react-hooks/use-if-still-mounted.spec.tsx index 0b4b1666856..5be4bb40837 100644 --- a/spec/javascripts/packages/document-capture/hooks/use-if-still-mounted-spec.jsx +++ b/app/javascript/packages/react-hooks/use-if-still-mounted.spec.tsx @@ -1,6 +1,6 @@ import sinon from 'sinon'; import { renderHook } from '@testing-library/react-hooks'; -import useIfStillMounted from '@18f/identity-document-capture/hooks/use-if-still-mounted'; +import useIfStillMounted from './use-if-still-mounted'; describe('document-capture/hooks/use-if-still-mounted', () => { it('returns function which executes callback if component is still mounted', () => { diff --git a/app/javascript/packages/document-capture/hooks/use-if-still-mounted.js b/app/javascript/packages/react-hooks/use-if-still-mounted.ts similarity index 77% rename from app/javascript/packages/document-capture/hooks/use-if-still-mounted.js rename to app/javascript/packages/react-hooks/use-if-still-mounted.ts index 7b6f66a6084..df4579fac22 100644 --- a/app/javascript/packages/document-capture/hooks/use-if-still-mounted.js +++ b/app/javascript/packages/react-hooks/use-if-still-mounted.ts @@ -19,18 +19,12 @@ function useIfStillMounted() { }; }); - /** - * @template {(...args) => any} T - * @param {T} fn - */ - const ifStillMounted = (fn) => - /** @type {T} */ ( - (...args) => { - if (isMounted.current) { - fn(...args); - } + const ifStillMounted = any>(fn: T) => + ((...args) => { + if (isMounted.current) { + fn(...args); } - ); + }) as T; return ifStillMounted; } diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 68354759319..8ad8a61c8d1 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -1,5 +1,3 @@ require('../app/components/index'); -require('../app/pw-toggle'); require('../app/print-personal-key'); require('../app/i18n-dropdown'); -require('../app/accessible-forms'); diff --git a/app/javascript/packs/pw-strength.js b/app/javascript/packs/pw-strength.js index 6559e254a69..9964d2518da 100644 --- a/app/javascript/packs/pw-strength.js +++ b/app/javascript/packs/pw-strength.js @@ -112,14 +112,7 @@ export function getForbiddenPasswords(element) { function analyzePw() { const { userAgent } = window.navigator; - const input = document.querySelector( - [ - '#password_form_password', - '#event_disavowal_password_reset_from_disavowal_form_password', - '#reset_password_form_password', - '#update_user_password_form_password', - ].join(','), - ); + const input = document.querySelector('.password-toggle__input'); const pwCntnr = document.getElementById('pw-strength-cntnr'); const pwStrength = document.getElementById('pw-strength-txt'); const pwFeedback = document.getElementById('pw-strength-feedback'); diff --git a/app/javascript/packs/ssn-field.js b/app/javascript/packs/ssn-field.js index 0e16028c7c6..c962ad412e4 100644 --- a/app/javascript/packs/ssn-field.js +++ b/app/javascript/packs/ssn-field.js @@ -1,32 +1,17 @@ import Cleave from 'cleave.js'; -import { t } from '@18f/identity-i18n'; /* eslint-disable no-new */ function formatSSNFieldAndLimitLength() { const inputs = document.querySelectorAll('input.ssn-toggle[type="password"]'); if (inputs) { - [].slice.call(inputs).forEach((input, i) => { - const el = ` -
- - -
`; - input.insertAdjacentHTML('afterend', el); - - const toggle = document.getElementById(`ssn-toggle-${i}`); + [].slice.call(inputs).forEach((input) => { + const toggle = document.querySelector(`[aria-controls="${input.id}"]`); let cleave; function sync() { const { value } = input; - input.type = toggle.checked ? 'text' : 'password'; cleave?.destroy(); if (toggle.checked) { cleave = new Cleave(input, { diff --git a/app/jobs/reports/total_ial2_costs_report.rb b/app/jobs/reports/total_ial2_costs_report.rb new file mode 100644 index 00000000000..9219908761a --- /dev/null +++ b/app/jobs/reports/total_ial2_costs_report.rb @@ -0,0 +1,71 @@ +require 'csv' + +module Reports + class TotalIal2CostsReport < BaseReport + REPORT_NAME = 'total-ial2-costs'.freeze + NUM_LOOKBACK_DAYS = 45 + + include GoodJob::ActiveJobExtensions::Concurrency + + good_job_control_concurrency_with( + total_limit: 1, + key: -> { "#{REPORT_NAME}-#{arguments.first}" }, + ) + + def perform(date) + results = transaction_with_timeout { query(date) } + + save_report(REPORT_NAME, to_csv(results), extension: 'csv') + end + + # @param [PG::Result] + # @return [String] + def to_csv(results) + CSV.generate do |csv| + csv << %w[ + date + ial + cost_type + count + ] + + results.each do |row| + csv << row.values_at('date', 'ial', 'cost_type', 'count') + end + end + end + + # @return [PG::Result] + def query(date) + finish = date.beginning_of_day + start = (finish - NUM_LOOKBACK_DAYS.days).beginning_of_day + + params = { + start: ActiveRecord::Base.connection.quote(start), + finish: ActiveRecord::Base.connection.quote(finish), + } + + sql = format(<<~SQL, params) + SELECT + DATE(sp_costs.created_at) AS date + , sp_costs.ial + , sp_costs.cost_type + , COUNT(*) AS count + FROM sp_costs + WHERE + %{start} <= sp_costs.created_at AND sp_costs.created_at <= %{finish} + AND sp_costs.ial > 1 + GROUP BY + sp_costs.ial + , sp_costs.cost_type + , DATE(sp_costs.created_at) + ORDER BY + sp_costs.ial + , sp_costs.cost_type + , DATE(sp_costs.created_at) + SQL + + ActiveRecord::Base.connection.execute(sql) + end + end +end diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 396fcab32d7..b6eb3952f87 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -122,7 +122,6 @@ def browser_attributes end # rubocop:disable Layout/LineLength - ACCOUNT_RESET = 'Account Reset' ACCOUNT_RESET_VISIT = 'Account deletion and reset visited' DOC_AUTH = 'Doc Auth' # visited or submitted is appended EMAIL_DELETION_REQUEST = 'Email Deletion Requested' @@ -130,11 +129,6 @@ def browser_attributes EMAIL_LANGUAGE_UPDATED = 'Email Language: Updated' EVENT_DISAVOWAL = 'Event disavowal visited' EVENT_DISAVOWAL_PASSWORD_RESET = 'Event disavowal password reset' - EVENT_DISAVOWAL_TOKEN_INVALID = 'Event disavowal token invalid' - EVENTS_VISIT = 'Events Page Visited' - EXTERNAL_REDIRECT = 'External Redirect' - FORGET_ALL_BROWSERS_SUBMITTED = 'Forget All Browsers Submitted' - FORGET_ALL_BROWSERS_VISITED = 'Forget All Browsers Visited' IDV_ADDRESS_VISIT = 'IdV: address visited' IDV_ADDRESS_SUBMITTED = 'IdV: address submitted' IDV_BASIC_INFO_VISIT = 'IdV: basic info visited' @@ -155,7 +149,6 @@ def browser_attributes IDV_INTRO_VISIT = 'IdV: intro visited' IDV_JURISDICTION_VISIT = 'IdV: jurisdiction visited' IDV_JURISDICTION_FORM = 'IdV: jurisdiction form submitted' - IDV_PERSONAL_KEY_DOWNLOADED = 'IdV: personal key downloaded' # previously "IdV: download personal key" IDV_PERSONAL_KEY_VISITED = 'IdV: personal key visited' IDV_PERSONAL_KEY_SUBMITTED = 'IdV: personal key submitted' IDV_PHONE_CONFIRMATION_FORM = 'IdV: phone confirmation form' @@ -225,12 +218,6 @@ def browser_attributes REMEMBERED_DEVICE_USED_FOR_AUTH = 'Remembered device used for authentication' REMOTE_LOGOUT_INITIATED = 'Remote Logout initiated' RETURN_TO_SP_CANCEL = 'Return to SP: Cancelled' - RETURN_TO_SP_FAILURE_TO_PROOF = 'Return to SP: Failed to proof' - RULES_OF_USE_VISIT = 'Rules Of Use Visited' - RULES_OF_USE_SUBMITTED = 'Rules Of Use Submitted' - SECURITY_EVENT_RECEIVED = 'RISC: Security event received' - SP_REVOKE_CONSENT_REVOKED = 'SP Revoke Consent: Revoked' - SP_REVOKE_CONSENT_VISITED = 'SP Revoke Consent: Visited' SP_HANDOFF_BOUNCED_DETECTED = 'SP handoff bounced detected' SP_HANDOFF_BOUNCED_VISIT = 'SP handoff bounced visited' SP_INACTIVE_VISIT = 'SP inactive visited' diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 03ae70dbafb..a94837570c2 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -1,3 +1,4 @@ +# rubocop:disable Metrics/ModuleLength module AnalyticsEvents # @identity.idp.event_name Account Reset # @param [Boolean] success @@ -136,7 +137,7 @@ def broken_personal_key_regenerated # @identity.idp.event_name Doc Auth Async # @param [String, nil] error error message # @param [String, nil] uuid document capture session uuid - # @param [String. nil] result_id document capture session result id + # @param [String, nil] result_id document capture session result id # When there is an error loading async results during the document authentication flow def doc_auth_async(error: nil, uuid: nil, result_id: nil, **extra) track_event('Doc Auth Async', error: error, uuid: uuid, result_id: result_id, **extra) @@ -179,6 +180,87 @@ def email_and_password_auth( ) end + # @identity.idp.event_name Event disavowal token invalid + # @param [Boolean] success + # @param [Hash] errors + # @param [Time, nil] event_created_at timestamp for the event + # @param [Time, nil] disavowed_device_last_used_at + # @param [String, nil] disavowed_device_user_agent + # @param [String, nil] disavowed_device_last_ip + # @param [Integer, nil] event_id events table id + # @param [String, nil] event_type (see Event#event_type) + # @param [String, nil] event_ip ip address for the event + # An invalid disavowal token was clicked + def event_disavowal_token_invalid( + success:, + errors:, + event_created_at: nil, + disavowed_device_last_used_at: nil, + disavowed_device_user_agent: nil, + disavowed_device_last_ip: nil, + event_id: nil, + event_type: nil, + event_ip: nil, + **extra + ) + track_event( + 'Event disavowal token invalid', + success: success, + errors: errors, + event_created_at: event_created_at, + disavowed_device_last_used_at: disavowed_device_last_used_at, + disavowed_device_user_agent: disavowed_device_user_agent, + disavowed_device_last_ip: disavowed_device_last_ip, + event_id: event_id, + event_type: event_type, + event_ip: event_ip, + **extra, + ) + end + + # @identity.idp.event_name Events Page Visited + # User visited the events page + def events_visit + track_event('Events Page Visited') + end + + # @identity.idp.event_name External Redirect + # @param [String] redirect_url URL user was directed to + # @param [String, nil] step which step + # @param [String, nil] location which part of a step, if applicable + # @param ["idv", String, nil] flow which flow + # User was redirected to a page outside the IDP + def external_redirect(redirect_url:, step: nil, location: nil, flow: nil, **extra) + track_event( + 'External Redirect', + redirect_url: redirect_url, + step: step, + location: location, + flow: flow, + **extra, + ) + end + + # @identity.idp.event_name Forget All Browsers Submitted + # The user chose to "forget all browsers" + def forget_all_browsers_submitted + track_event('Forget All Browsers Submitted') + end + + # @identity.idp.event_name Forget All Browsers Visited + # The user visited the "forget all browsers" page + def forget_all_browsers_visited + track_event('Forget All Browsers Visited') + end + + # @deprecated + # A user has downloaded their personal key. This event is no longer emitted. + # @identity.idp.event_name IdV: personal key downloaded + # @identity.idp.previous_event_name IdV: download personal key + def idv_personal_key_downloaded + track_event('IdV: personal key downloaded') + end + # @identity.idp.event_name IdV: phone confirmation otp submitted # @param [Boolean] success # @param [Hash] errors @@ -302,6 +384,93 @@ def proofing_document_result_missing track_event('Proofing Document Result Missing') end + # @identity.idp.event_name Return to SP: Failed to proof + # Tracks when a user is redirected back to the service provider after failing to proof. + # @param [String] redirect_url the url of the service provider + # @param [String] flow + # @param [String] step + # @param [String] location + def return_to_sp_failure_to_proof(redirect_url:, flow: nil, step: nil, location: nil, **extra) + track_event( + 'Return to SP: Failed to proof', + redirect_url: redirect_url, + flow: flow, + step: step, + location: location, + **extra, + ) + end + + # @identity.idp.event_name Rules of Use Visited + # Tracks when rules of use is visited + def rules_of_use_visit + track_event('Rules of Use Visited') + end + + # @identity.idp.event_name Rules of Use Submitted + # Tracks when rules of use is submitted with a success or failure + # @param [Boolean] success + # @param [Hash] errors + def rules_of_use_submitted(success: nil, errors: nil, **extra) + track_event( + 'Rules of Use Submitted', + success: success, + errors: errors, + **extra, + ) + end + + # @identity.idp.event_name RISC: Security event received + # Tracks when security event is received + # @param [Boolean] success + # @param [String] error_code + # @param [Hash] errors + # @param [String] jti + # @param [String] user_id + # @param [String] client_id + def security_event_received( + success:, + error_code: nil, + errors: nil, + jti: nil, + user_id: nil, + client_id: nil, + **extra + ) + track_event( + 'RISC: Security event received', + success: success, + error_code: error_code, + errors: errors, + jti: jti, + user_id: user_id, + client_id: client_id, + **extra, + ) + end + + # @identity.idp.event_name SP Revoke Consent: Revoked + # Tracks when service provider consent is revoked + # @param [String] issuer issuer of the service provider consent to be revoked + def sp_revoke_consent_revoked(issuer:, **extra) + track_event( + 'SP Revoke Consent: Revoked', + issuer: issuer, + **extra, + ) + end + + # @identity.idp.event_name SP Revoke Consent: Visited + # Tracks when the page to revoke consent (unlink from) a service provider visited + # @param [String] issuer which issuer + def sp_revoke_consent_visited(issuer:, **extra) + track_event( + 'SP Revoke Consent: Visited', + issuer: issuer, + **extra, + ) + end + # @identity.idp.event_name SAML Auth Request # @param [Integer] requested_ial # @param [String] service_provider @@ -321,3 +490,4 @@ def saml_auth_request( ) end end +# rubocop:enable Metrics/ModuleLength diff --git a/app/services/db/sp_return_log.rb b/app/services/db/sp_return_log.rb index 4e2880364f5..e4deb0d5c32 100644 --- a/app/services/db/sp_return_log.rb +++ b/app/services/db/sp_return_log.rb @@ -1,6 +1,5 @@ module Db class SpReturnLog - # rubocop:disable Rails/SkipsModelValidations def self.create_return(request_id:, user_id:, billable:, ial:, issuer:, requested_at:) ::SpReturnLog.create!( request_id: request_id, @@ -12,15 +11,7 @@ def self.create_return(request_id:, user_id:, billable:, ial:, issuer:, requeste returned_at: Time.zone.now, ) rescue ActiveRecord::RecordNotUnique - # Can be removed after deploy of RC 185 - ::SpReturnLog.where(request_id: request_id).update_all( - user_id: user_id, - returned_at: Time.zone.now, - billable: billable, - ial: ial, - ) nil end - # rubocop:enable Rails/SkipsModelValidations end end diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb index 039b40ad92f..33580e0d106 100644 --- a/app/views/devise/passwords/edit.html.erb +++ b/app/views/devise/passwords/edit.html.erb @@ -10,8 +10,11 @@ ) do |f| %> <%= f.input :reset_password_token, as: :hidden %> <%= f.full_error :reset_password_token %> - <%= f.input :password, label: t('forms.passwords.edit.labels.password'), required: true, - input_html: { class: 'password-toggle' } %> + <%= render PasswordToggleComponent.new( + form: f, + label: t('forms.passwords.edit.labels.password'), + required: true, + ) %> <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> <%= f.button :submit, t('forms.passwords.edit.buttons.submit'), class: 'usa-button--big margin-bottom-4' %> <% end %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 4fee3ef8316..49f364b450a 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -26,10 +26,7 @@ input_html: { class: 'margin-bottom-6', autocorrect: 'off', aria: { invalid: false } } %> - <%= f.input :password, - label: t('account.index.password'), - required: true, - input_html: { aria: { invalid: false }, class: 'password-toggle' } %> + <%= render PasswordToggleComponent.new(form: f, required: true) %> <%= f.input :request_id, as: :hidden, input_html: { value: @request_id } %>
<%= submit_tag t('links.next'), class: 'usa-button usa-button--primary usa-button--full-width margin-bottom-2' %> diff --git a/app/views/event_disavowal/new.html.erb b/app/views/event_disavowal/new.html.erb index c491caf9aa6..95355152ba0 100644 --- a/app/views/event_disavowal/new.html.erb +++ b/app/views/event_disavowal/new.html.erb @@ -10,8 +10,11 @@ ) do |f| %> <%= f.input :disavowal_token, as: :hidden, input_html: { value: @disavowal_token, name: :disavowal_token } %> - <%= f.input :password, label: t('forms.passwords.edit.labels.password'), required: true, - input_html: { aria: { invalid: false }, class: 'password-toggle' } %> + <%= render PasswordToggleComponent.new( + form: f, + label: t('forms.passwords.edit.labels.password'), + required: true, + ) %> <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> <%= f.button :submit, t('forms.passwords.edit.buttons.submit'), class: 'usa-button--big usa-button--wide margin-bottom-4' %> <% end %> diff --git a/app/views/idv/cancellations/destroy.html.erb b/app/views/idv/cancellations/destroy.html.erb index 7fb76ce6add..43c968caaba 100644 --- a/app/views/idv/cancellations/destroy.html.erb +++ b/app/views/idv/cancellations/destroy.html.erb @@ -9,7 +9,7 @@

<%= t('doc_auth.instructions.switch_back') %>

<%= image_tag(asset_url('idv/switch.png'), width: 193) %> <% else %> -
    +
    • <%= t('idv.cancel.warnings.warning_2') %>
    • <%= t('idv.cancel.warnings.warning_3', app_name: APP_NAME) %>
    • <%= t('idv.cancel.warnings.warning_4', app_name: APP_NAME) %>
    • diff --git a/app/views/idv/cancellations/new.html.erb b/app/views/idv/cancellations/new.html.erb index 13ae10bdb3f..c324529ec5c 100644 --- a/app/views/idv/cancellations/new.html.erb +++ b/app/views/idv/cancellations/new.html.erb @@ -22,7 +22,7 @@

      <%= t('idv.cancel.warnings.hybrid') %>

      <% else %>

      <%= t('sign_up.cancel.warning_header') %>

      -
        +
        • <%= t('idv.cancel.warnings.warning_1') %>
        • <%= t('idv.cancel.warnings.warning_2') %>
        • <%= t('idv.cancel.warnings.warning_3', app_name: APP_NAME) %>
        • diff --git a/app/views/idv/doc_auth/verify.html.erb b/app/views/idv/doc_auth/verify.html.erb index a921705ab4e..150e0cf4ce7 100644 --- a/app/views/idv/doc_auth/verify.html.erb +++ b/app/views/idv/doc_auth/verify.html.erb @@ -64,7 +64,7 @@ url_for, method: :put, form: { - class: 'button_to read-after-submit', + class: 'button_to', data: { form_steps_wait: '', error_message: t('idv.failure.exceptions.internal_error'), diff --git a/app/views/idv/forgot_password/new.html.erb b/app/views/idv/forgot_password/new.html.erb index edcbfd81e75..056ce94d1de 100644 --- a/app/views/idv/forgot_password/new.html.erb +++ b/app/views/idv/forgot_password/new.html.erb @@ -1,21 +1,24 @@ <% title t('titles.idv.reset_password') %> -<%= image_tag(asset_url('alert/forgot.svg'), width: 54, class: 'margin-bottom-2') %> +<%= render StatusPageComponent.new(status: :info, icon: :question) do |c| %> + <% c.header { t('idv.forgot_password.modal_header') } %> -<%= render PageHeadingComponent.new.with_content(t('idv.forgot_password.modal_header')) %> +
            + <% t('idv.forgot_password.warnings').each do |warning| %> +
          • <%= warning %>
          • + <% end %> +
          -
          + <% c.action_button( + action: ->(**tag_options, &block) do + link_to(idv_review_path, **tag_options, &block) + end, + ) { t('idv.forgot_password.try_again') } %> -
            -
          • <%= t('idv.forgot_password.warnings.warning_1') %>
          • -
          • <%= t('idv.forgot_password.warnings.warning_2') %>
          • -
          - -
          - <%= link_to t('idv.forgot_password.try_again'), idv_review_path, - class: 'usa-button usa-button--big usa-button--wide' %> -
          -
          - <%= button_to t('idv.forgot_password.reset_password'), idv_forgot_password_path, - method: :post, class: 'usa-button usa-button--big usa-button--wide usa-button--outline' %> -
          + <% c.action_button( + action: ->(**tag_options, &block) do + button_to(idv_forgot_password_path, method: :post, **tag_options, &block) + end, + outline: true, + ) { t('idv.forgot_password.reset_password') } %> +<% end %> diff --git a/app/views/idv/review/new.html.erb b/app/views/idv/review/new.html.erb index 74e15e9cd10..31cb2169ed0 100644 --- a/app/views/idv/review/new.html.erb +++ b/app/views/idv/review/new.html.erb @@ -20,16 +20,17 @@ MarketingSite.security_url, ) %> -<%= validated_form_for( +<%= simple_form_for( current_user, url: idv_review_path, html: { autocomplete: 'off', method: :put, class: 'margin-top-6' }, ) do |f| %> - <%= f.input :password, label: t('idv.form.password'), required: true, - input_html: { aria: { invalid: false }, class: 'password-toggle' }, wrapper: false %> - - <%= t('simple_form.required.text') %> - + <%= render PasswordToggleComponent.new( + form: f, + label: t('idv.form.password'), + required: true, + wrapper_html: { class: 'margin-bottom-0' }, + ) %>
          <%= t( 'idv.forgot_password.link_html', diff --git a/app/views/mfa_confirmation/new.html.erb b/app/views/mfa_confirmation/new.html.erb index 85113d2590b..8301994ffa2 100644 --- a/app/views/mfa_confirmation/new.html.erb +++ b/app/views/mfa_confirmation/new.html.erb @@ -13,7 +13,7 @@ url: reauthn_user_password_path, html: { autocomplete: 'off', method: 'post', class: 'margin-top-6' }, ) do |f| %> - <%= f.input :password, required: true, input_html: { aria: { invalid: false }, class: 'password-toggle' } %> + <%= render PasswordToggleComponent.new(form: f, required: true) %> <%= f.button :submit, t('forms.buttons.continue'), class: 'display-block margin-y-5 usa-button--big usa-button--wide' %> <% end %> <%= render 'shared/cancel', link: account_path %> diff --git a/app/views/password_capture/new.html.erb b/app/views/password_capture/new.html.erb index bb8a35d9819..a3926cd5d21 100644 --- a/app/views/password_capture/new.html.erb +++ b/app/views/password_capture/new.html.erb @@ -9,8 +9,11 @@ method: :post, html: { autocomplete: 'off', class: 'margin-top-6' }, ) do |f| %> - <%= f.input :password, label: t('account.index.password'), required: true, - input_html: { aria: { invalid: false }, class: 'password-toggle' } %> + <%= render PasswordToggleComponent.new( + form: f, + label: t('account.index.password'), + required: true, + ) %> <%= f.button :submit, t('forms.buttons.submit.default'), class: 'display-block margin-y-5 usa-button--big usa-button--wide' %> <% end %> diff --git a/app/views/shared/_ssn_field.html.erb b/app/views/shared/_ssn_field.html.erb index 3e9c662b68d..522a2ba6f60 100644 --- a/app/views/shared/_ssn_field.html.erb +++ b/app/views/shared/_ssn_field.html.erb @@ -4,11 +4,13 @@ locals: %> <%# maxlength set and includes '-' delimiters to work around cleave bug %> -<%= render ValidatedFieldComponent.new( +<%= render PasswordToggleComponent.new( name: :ssn, form: f, as: :password, label: t('idv.form.ssn_label_html'), + toggle_label: t('forms.ssn.show'), + toggle_position: :bottom, hint: t('forms.example') + ' 123-45-6789', required: true, pattern: '^\d{3}-?\d{2}-?\d{4}$', diff --git a/app/views/sign_up/passwords/new.html.erb b/app/views/sign_up/passwords/new.html.erb index 9d798d4225c..94c32bdc1e5 100644 --- a/app/views/sign_up/passwords/new.html.erb +++ b/app/views/sign_up/passwords/new.html.erb @@ -11,10 +11,12 @@ method: :post, html: { autocomplete: 'off' }, ) do |f| %> - <%= f.input :password, required: true, - label: t('forms.password'), - input_html: { aria: { invalid: false, describedby: 'password-description' }, - class: 'password-toggle' } %> + <%= render PasswordToggleComponent.new( + form: f, + label: t('forms.password'), + required: true, + input_html: { aria: { describedby: 'password-description' } }, + ) %> <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> <%= hidden_field_tag :confirmation_token, @confirmation_token, id: 'confirmation_token' %> <%= f.input :request_id, as: :hidden, input_html: { value: params[:request_id] || request_id } %> diff --git a/app/views/two_factor_authentication/webauthn_verification/show.html.erb b/app/views/two_factor_authentication/webauthn_verification/show.html.erb index 10d57941cf4..d8b494d88d7 100644 --- a/app/views/two_factor_authentication/webauthn_verification/show.html.erb +++ b/app/views/two_factor_authentication/webauthn_verification/show.html.erb @@ -11,7 +11,7 @@ url: login_two_factor_webauthn_path, method: :patch, html: { - class: 'margin-bottom-1 read-after-submit', + class: 'margin-bottom-1', id: 'webauthn_form', }, ) do |f| %> diff --git a/app/views/users/delete/show.html.erb b/app/views/users/delete/show.html.erb index 2e30b14b4ea..78fec448400 100644 --- a/app/views/users/delete/show.html.erb +++ b/app/views/users/delete/show.html.erb @@ -22,12 +22,11 @@ <%= t('users.delete.instructions') %>

          - <%= render ValidatedFieldComponent.new( + <%= render PasswordToggleComponent.new( form: f, name: :password, label: t('idv.form.password'), required: true, - input_html: { class: 'password-toggle' }, ) %> <%= f.button( diff --git a/app/views/users/passwords/edit.html.erb b/app/views/users/passwords/edit.html.erb index e1760476231..93f4d845129 100644 --- a/app/views/users/passwords/edit.html.erb +++ b/app/views/users/passwords/edit.html.erb @@ -11,8 +11,13 @@ html: { autocomplete: 'off', method: :patch } ) do |f| %> <%= f.error_notification %> - <%= f.input :password, label: t('forms.passwords.edit.labels.password'), required: true, - input_html: { aria: { invalid: false, describedby: 'password-description' }, class: 'password-toggle' } %> + <%= render PasswordToggleComponent.new( + form: f, + name: :password, + label: t('forms.passwords.edit.labels.password'), + required: true, + input_html: { aria: { describedby: 'password-description' } }, + ) %> <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> <%= f.button :submit, t('forms.buttons.submit.update'), class: 'usa-button--big usa-button--wide margin-top-2 margin-bottom-4' %> <% end %> diff --git a/app/views/users/verify_password/new.html.erb b/app/views/users/verify_password/new.html.erb index d23d668bd37..a5f8b8932a3 100644 --- a/app/views/users/verify_password/new.html.erb +++ b/app/views/users/verify_password/new.html.erb @@ -10,8 +10,12 @@ current_user, url: update_verify_password_path, html: { autocomplete: 'off', method: :put } ) do |f| %> - <%= f.input :password, label: t('idv.form.password'), required: true, - input_html: { aria: { invalid: false }, class: 'password-toggle' } %> + <%= render PasswordToggleComponent.new( + form: f, + name: :password, + label: t('idv.form.password'), + required: true, + ) %> <%= f.button :submit, t('forms.buttons.continue'), class: 'usa-button--big usa-button--wide' %> <% end %> diff --git a/app/views/users/webauthn_setup/new.html.erb b/app/views/users/webauthn_setup/new.html.erb index 5d3b7c10c60..83533c384ca 100644 --- a/app/views/users/webauthn_setup/new.html.erb +++ b/app/views/users/webauthn_setup/new.html.erb @@ -13,7 +13,7 @@ url: webauthn_setup_path, method: :patch, html: { - class: 'margin-top-4 margin-bottom-1 read-after-submit', + class: 'margin-top-4 margin-bottom-1', id: 'webauthn_form', }, ) do |f| %> diff --git a/babel.config.js b/babel.config.js index c689971aa24..b36b7123923 100644 --- a/babel.config.js +++ b/babel.config.js @@ -18,6 +18,7 @@ module.exports = (api) => { ], ], plugins: [ + ['@babel/plugin-proposal-decorators', { version: 'legacy' }], [ 'polyfill-corejs3', { diff --git a/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index bd4cc0ac2fd..bab2298ba18 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -69,7 +69,6 @@ cron: cron_24h, args: -> { [Time.zone.today] }, }, - # Send Sp Success Rate Report to S3 # Proofing Costs Report to S3 proofing_costs: { class: 'Reports::ProofingCostsReport', @@ -117,6 +116,12 @@ cron: cron_24h, args: -> { [Time.zone.today] }, }, + # Total IAL2 Costs Report to S3 + total_ial2_costs: { + class: 'Reports::TotalIal2CostsReport', + cron: cron_24h, + args: -> { [Time.zone.today] }, + }, # SP Active Users Report to S3 sp_active_users_report: { class: 'Reports::SpActiveUsersReport', diff --git a/config/locales/components/en.yml b/config/locales/components/en.yml index be115e26063..fdce36fbd70 100644 --- a/config/locales/components/en.yml +++ b/config/locales/components/en.yml @@ -1,12 +1,16 @@ --- en: components: + password_toggle: + label: Password + toggle_label: Show password phone_input: country_code_label: Country code status_page: icons: error: Error lock: Lock + question: Question warning: Warning troubleshooting_options: default_heading: 'Having trouble? Here’s what you can do:' diff --git a/config/locales/components/es.yml b/config/locales/components/es.yml index c1007e6480c..84b9e7b8c4c 100644 --- a/config/locales/components/es.yml +++ b/config/locales/components/es.yml @@ -1,12 +1,16 @@ --- es: components: + password_toggle: + label: Contraseña + toggle_label: Mostrar contraseña phone_input: country_code_label: Código del país status_page: icons: error: Error lock: Candado + question: Pregunta warning: Advertencia troubleshooting_options: default_heading: '¿Tiene alguna dificultad? Esto es lo que puede hacer:' diff --git a/config/locales/components/fr.yml b/config/locales/components/fr.yml index 04fc04e85d8..6dac244aa70 100644 --- a/config/locales/components/fr.yml +++ b/config/locales/components/fr.yml @@ -1,12 +1,16 @@ --- fr: components: + password_toggle: + label: Mot de passe + toggle_label: Afficher le mot de passe phone_input: country_code_label: Code pays status_page: icons: error: Erreur lock: Serrure + question: Question warning: Avertissement troubleshooting_options: default_heading: 'Des difficultés? Voici ce que vous pouvez faire:' diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml index f5e9a1523fd..5701f924f9c 100644 --- a/config/locales/forms/en.yml +++ b/config/locales/forms/en.yml @@ -59,7 +59,6 @@ en: submit: Change password labels: password: New password - show: Show password personal_key: alternative: Don’t have your personal key? confirmation_label: Personal key @@ -104,7 +103,8 @@ en: totp_step_4: Enter the temporary code from your app two_factor: backup_code: Security code - code: One-time security code + # removed "security code" form labels as they trigger ios credit card reader + code: One-time code personal_key: Personal key try_again: Use another phone number two_factor_choice: diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml index 7fadf7a1eb9..1a43385f069 100644 --- a/config/locales/forms/es.yml +++ b/config/locales/forms/es.yml @@ -64,7 +64,6 @@ es: submit: Cambiar la contraseña labels: password: Nueva contraseña - show: Mostrar contraseña personal_key: alternative: '¿No tiene su clave personal?' confirmation_label: Clave personal @@ -111,7 +110,8 @@ es: totp_step_4: Ingrese el código temporal de su aplicación two_factor: backup_code: Código de seguridad - code: Código de seguridad de sólo un uso + # removed "security code" form labels as they trigger ios credit card reader + code: Código de un solo uso personal_key: Clave personal try_again: Use otro número de teléfono. two_factor_choice: diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index 5e35632574c..a81b7d5bb73 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -65,7 +65,6 @@ fr: submit: Changer le mot de passe labels: password: Nouveau mot de passe - show: Afficher le mot de passe personal_key: alternative: Vous n’avez pas votre clé personnelle? confirmation_label: Clé personnelle @@ -112,7 +111,8 @@ fr: totp_step_4: Entrez le code temporaire de votre application two_factor: backup_code: Code de sécurité - code: Code de sécurité + # removed "security code" form labels as they trigger ios credit card reader + code: Code à usage unique personal_key: Clé personnelle try_again: Utilisez un autre numéro de téléphone two_factor_choice: diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 15eb6a5b3c3..e3d652b5a9d 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -69,9 +69,9 @@ en: reset_password: Reset password try_again: Try again warnings: - warning_1: If you forgot your password, you’ll need to reset it and fill out the + - If you forgot your password, you’ll need to reset it and fill out the form again. - warning_2: You’ll have to re-enter your personal information, like your name, + - You’ll have to re-enter your personal information, like your name, state-issued ID, etc. form: address1: Address diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index 308d4ae7ff1..478e11ee897 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -76,8 +76,8 @@ es: reset_password: Restablecer la contraseña try_again: Inténtalo de nuevo warnings: - warning_1: Si olvidó su contraseña, deberá restablecerla y completarla nuevamente. - warning_2: Tendrá que volver a ingresar su información personal, como su nombre, + - Si olvidó su contraseña, deberá restablecerla y completarla nuevamente. + - Tendrá que volver a ingresar su información personal, como su nombre, documento de identidad emitido por el estado, etc. form: address1: Dirección diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index 551dba68c97..c9855f7bfb8 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -79,9 +79,9 @@ fr: reset_password: Réinitialiser le mot de passe try_again: Réessayer warnings: - warning_1: Si vous avez oublié votre mot de passe, vous devrez le réinitialiser + - Si vous avez oublié votre mot de passe, vous devrez le réinitialiser et remplir à nouveau le formulaire. - warning_2: Vous devrez ressaisir vos informations personnelles, comme votre nom, + - Vous devrez ressaisir vos informations personnelles, comme votre nom, pièce d’identité officielle, etc. form: address1: Adresse diff --git a/config/routes.rb b/config/routes.rb index 46cea19738e..2815c0bf86b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -276,8 +276,6 @@ get '/come_back_later' => 'come_back_later#show' get '/personal_key' => 'personal_key#show' post '/personal_key' => 'personal_key#update' - # Remove this after the next deploy - get '/download_personal_key' => 'personal_key#download' get '/forgot_password' => 'forgot_password#new' post '/forgot_password' => 'forgot_password#update' get '/otp_delivery_method' => 'otp_delivery_method#new' diff --git a/package.json b/package.json index cd977dd0307..2bada0b0363 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@babel/core": "^7.15.5", + "@babel/plugin-proposal-decorators": "^7.17.2", "@babel/preset-env": "^7.15.6", "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "^7.16.7", @@ -56,6 +57,7 @@ "@types/react": "^17.0.39", "@types/react-dom": "^17.0.11", "@types/sinon": "^10.0.11", + "@types/sinon-chai": "^3.2.8", "@typescript-eslint/eslint-plugin": "^5.12.0", "@typescript-eslint/parser": "^5.12.0", "chai": "^4.2.0", diff --git a/spec/components/password_toggle_component_spec.rb b/spec/components/password_toggle_component_spec.rb new file mode 100644 index 00000000000..36ae51bfec0 --- /dev/null +++ b/spec/components/password_toggle_component_spec.rb @@ -0,0 +1,101 @@ +require 'rails_helper' + +RSpec.describe PasswordToggleComponent, type: :component do + include SimpleForm::ActionViewExtensions::FormHelper + + let(:lookup_context) { ActionView::LookupContext.new(ActionController::Base.view_paths) } + let(:view_context) { ActionView::Base.new(lookup_context, {}, controller) } + let(:form) { SimpleForm::FormBuilder.new('', {}, view_context, {}) } + let(:options) { {} } + + subject(:rendered) { render_inline PasswordToggleComponent.new(form: form, **options) } + + it 'renders default markup' do + expect(rendered).to have_css('lg-password-toggle.password-toggle--toggle-top') + expect(rendered).to have_field(t('components.password_toggle.label'), type: :password) + expect(rendered).to have_field(t('components.password_toggle.toggle_label'), type: :checkbox) + end + + it 'renders with accessible linking between toggle and input' do + input_id = rendered.css('[type=password]').first.attr('id') + + expect(input_id).to be_present + expect(rendered).to have_css("[aria-controls='#{input_id}']") + end + + describe '#label' do + context 'with custom label' do + let(:label) { 'Custom Label' } + let(:options) { { label: label } } + + it 'renders custom field label' do + expect(rendered).to have_field(label, type: :password) + end + end + end + + describe '#toggle_label' do + context 'with custom label' do + let(:toggle_label) { 'Custom Toggle Label' } + let(:options) { { toggle_label: toggle_label } } + + it 'renders custom field label' do + expect(rendered).to have_field(toggle_label, type: :checkbox) + end + end + end + + describe '#toggle_position' do + context 'with top toggle position' do + let(:options) { { toggle_position: :top } } + + it 'renders modifier class' do + expect(rendered).to have_css('lg-password-toggle.password-toggle--toggle-top') + end + end + + context 'with bottom toggle position' do + let(:options) { { toggle_position: :bottom } } + + it 'renders modifier class' do + expect(rendered).to have_css('lg-password-toggle.password-toggle--toggle-bottom') + end + end + end + + describe '#toggle_id' do + it 'is unique across instances' do + toggle_one = PasswordToggleComponent.new(form: form) + toggle_two = PasswordToggleComponent.new(form: form) + + expect(toggle_one.toggle_id).to be_present + expect(toggle_two.toggle_id).to be_present + expect(toggle_one.toggle_id).not_to eq(toggle_two.toggle_id) + end + end + + describe '#input_id' do + it 'is unique across instances' do + toggle_one = PasswordToggleComponent.new(form: form) + toggle_two = PasswordToggleComponent.new(form: form) + + expect(toggle_one.input_id).to be_present + expect(toggle_two.input_id).to be_present + expect(toggle_one.input_id).not_to eq(toggle_two.input_id) + end + end + + describe '#field' do + context 'with field options' do + let(:options) do + { input_html: { class: 'my-custom-field', data: { foo: 'bar' } }, required: true } + end + + it 'forwards field options' do + expect(rendered).to have_css( + '.password-toggle__input.my-custom-field[data-foo="bar"][required]', + ) + end + end + end +end diff --git a/spec/components/status_page_component_spec.rb b/spec/components/status_page_component_spec.rb index 201d90583bf..0afcf0268c7 100644 --- a/spec/components/status_page_component_spec.rb +++ b/spec/components/status_page_component_spec.rb @@ -70,4 +70,10 @@ render_inline StatusPageComponent.new(status: :warning, icon: :foo) end.to raise_error(ArgumentError) end + + it 'raises error if no default icon associated with status' do + expect do + render_inline StatusPageComponent.new(status: :info) + end.to raise_error(ArgumentError) + end end diff --git a/spec/components/validated_field_component_spec.rb b/spec/components/validated_field_component_spec.rb index d94290780b6..7d9e890620d 100644 --- a/spec/components/validated_field_component_spec.rb +++ b/spec/components/validated_field_component_spec.rb @@ -25,6 +25,12 @@ render_inline(described_class.new(**options)) end + it 'renders aria-describedby to establish connection between input and error message' do + field = rendered.at_css('input') + + expect(field.attr('aria-describedby')).to start_with('validated-field-error-') + end + describe 'error message strings' do subject(:strings) do script = rendered.at_css('script[type="application/json"]') @@ -59,4 +65,16 @@ end end end + + context 'with tag options' do + context 'with aria tag option' do + let(:tag_options) { { input_html: { aria: { describedby: 'foo' } } } } + + it 'merges aria-describedby with the one applied by the field' do + field = rendered.at_css('input') + + expect(field.attr('aria-describedby')).to start_with('foo validated-field-error-') + end + end + end end diff --git a/spec/controllers/event_disavowal_controller_spec.rb b/spec/controllers/event_disavowal_controller_spec.rb index 0856ed63653..e6e7e5174f9 100644 --- a/spec/controllers/event_disavowal_controller_spec.rb +++ b/spec/controllers/event_disavowal_controller_spec.rb @@ -41,7 +41,7 @@ event.update!(disavowed_at: Time.zone.now) expect(@analytics).to receive(:track_event).with( - Analytics::EVENT_DISAVOWAL_TOKEN_INVALID, + 'Event disavowal token invalid', build_analytics_hash( success: false, errors: { event: [t('event_disavowals.errors.event_already_disavowed')] }, @@ -55,7 +55,7 @@ event.update!(disavowed_at: Time.zone.now) expect(@analytics).to receive(:track_event).with( - Analytics::EVENT_DISAVOWAL_TOKEN_INVALID, + 'Event disavowal token invalid', build_analytics_hash( success: false, errors: { event: [t('event_disavowals.errors.event_already_disavowed')] }, @@ -127,7 +127,7 @@ event.update!(disavowed_at: Time.zone.now) expect(@analytics).to receive(:track_event).with( - Analytics::EVENT_DISAVOWAL_TOKEN_INVALID, + 'Event disavowal token invalid', build_analytics_hash( success: false, errors: { event: [t('event_disavowals.errors.event_already_disavowed')] }, @@ -150,7 +150,7 @@ it 'errors' do expect(@analytics).to receive(:track_event).with( - Analytics::EVENT_DISAVOWAL_TOKEN_INVALID, + 'Event disavowal token invalid', build_analytics_hash( success: false, errors: { diff --git a/spec/controllers/idv/personal_key_controller_spec.rb b/spec/controllers/idv/personal_key_controller_spec.rb index ed711a1d982..8c4759b2853 100644 --- a/spec/controllers/idv/personal_key_controller_spec.rb +++ b/spec/controllers/idv/personal_key_controller_spec.rb @@ -187,45 +187,4 @@ def index end end end - - describe '#download' do - before do - stub_idv_session - stub_analytics - end - - it 'allows download of code' do - subject.idv_session.create_profile_from_applicant_with_password(password) - code = subject.idv_session.personal_key - - get :show - get :download - - expect(response.body).to eq(code + "\r\n") - expect(response.header['Content-Type']).to eq('text/plain') - expect(@analytics).to have_logged_event(Analytics::IDV_PERSONAL_KEY_DOWNLOADED, success: true) - end - - it 'recovers pii and verifies personal key digest with the code' do - get :show - get :download - - code = response.body.chomp - - expect(PersonalKeyGenerator.new(user).verify(code)).to eq true - expect(user.profiles.first.recover_pii(normalize_personal_key(code))).to eq( - subject.idv_session.pii, - ) - end - - it 'is a bad request when there is no personal_key in the session' do - get :download - - expect(response).to be_bad_request - expect(@analytics).to have_logged_event( - Analytics::IDV_PERSONAL_KEY_DOWNLOADED, - success: false, - ) - end - end end diff --git a/spec/controllers/redirect/help_center_controller_spec.rb b/spec/controllers/redirect/help_center_controller_spec.rb index ae752fa2d96..f5bd791df63 100644 --- a/spec/controllers/redirect/help_center_controller_spec.rb +++ b/spec/controllers/redirect/help_center_controller_spec.rb @@ -16,7 +16,7 @@ context 'without help center article' do it 'redirects to the root url' do expect(response).to redirect_to root_url - expect(@analytics).not_to have_logged_event(Analytics::EXTERNAL_REDIRECT) + expect(@analytics).not_to have_logged_event('External Redirect') end end @@ -26,7 +26,7 @@ it 'redirects to the root url' do expect(response).to redirect_to root_url - expect(@analytics).not_to have_logged_event(Analytics::EXTERNAL_REDIRECT) + expect(@analytics).not_to have_logged_event('External Redirect') end end @@ -41,7 +41,7 @@ ) expect(response).to redirect_to redirect_url expect(@analytics).to have_logged_event( - Analytics::EXTERNAL_REDIRECT, + 'External Redirect', redirect_url: redirect_url, ) end @@ -53,7 +53,7 @@ response expect(@analytics).to have_logged_event( - Analytics::EXTERNAL_REDIRECT, + 'External Redirect', redirect_url: MarketingSite.help_center_article_url( category: 'verify-your-identity', article: 'accepted-state-issued-identification', diff --git a/spec/controllers/redirect/return_to_sp_controller_spec.rb b/spec/controllers/redirect/return_to_sp_controller_spec.rb index ff2e0c15772..79574f757b2 100644 --- a/spec/controllers/redirect/return_to_sp_controller_spec.rb +++ b/spec/controllers/redirect/return_to_sp_controller_spec.rb @@ -99,8 +99,8 @@ expect(response).to redirect_to('https://sp.gov/failure_to_proof') expect(@analytics).to have_received(:track_event).with( - Analytics::RETURN_TO_SP_FAILURE_TO_PROOF, - redirect_url: 'https://sp.gov/failure_to_proof', + 'Return to SP: Failed to proof', + hash_including(redirect_url: 'https://sp.gov/failure_to_proof'), ) end end @@ -110,10 +110,12 @@ get 'failure_to_proof', params: { step: 'first', location: 'bottom' } expect(@analytics).to have_received(:track_event).with( - Analytics::RETURN_TO_SP_FAILURE_TO_PROOF, - redirect_url: a_kind_of(String), - step: 'first', - location: 'bottom', + 'Return to SP: Failed to proof', + hash_including( + redirect_url: a_kind_of(String), + step: 'first', + location: 'bottom', + ), ) end end diff --git a/spec/controllers/risc/security_events_controller_spec.rb b/spec/controllers/risc/security_events_controller_spec.rb index 97f834b66ee..b3eadf0ebe6 100644 --- a/spec/controllers/risc/security_events_controller_spec.rb +++ b/spec/controllers/risc/security_events_controller_spec.rb @@ -48,7 +48,7 @@ it 'tracks an successful in analytics' do stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::SECURITY_EVENT_RECEIVED, + with('RISC: Security event received', client_id: service_provider.issuer, error_code: nil, errors: {}, @@ -77,7 +77,7 @@ it 'tracks an error event in analytics' do stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::SECURITY_EVENT_RECEIVED, + with('RISC: Security event received', client_id: service_provider.issuer, error_code: SecurityEventForm::ErrorCodes::JWT_AUD, errors: kind_of(Hash), diff --git a/spec/controllers/users/forget_all_browsers_controller_spec.rb b/spec/controllers/users/forget_all_browsers_controller_spec.rb index 28a066ed116..4fa3af9253d 100644 --- a/spec/controllers/users/forget_all_browsers_controller_spec.rb +++ b/spec/controllers/users/forget_all_browsers_controller_spec.rb @@ -30,7 +30,7 @@ it 'logs an analytics event for visiting' do stub_analytics - expect(@analytics).to receive(:track_event).with(Analytics::FORGET_ALL_BROWSERS_VISITED) + expect(@analytics).to receive(:track_event).with('Forget All Browsers Visited') subject end @@ -58,7 +58,7 @@ it 'logs an analytics event for forgetting' do stub_analytics - expect(@analytics).to receive(:track_event).with(Analytics::FORGET_ALL_BROWSERS_SUBMITTED) + expect(@analytics).to receive(:track_event).with('Forget All Browsers Submitted') subject end diff --git a/spec/controllers/users/rules_of_use_controller_spec.rb b/spec/controllers/users/rules_of_use_controller_spec.rb index 388a279e4eb..6b20923ba62 100644 --- a/spec/controllers/users/rules_of_use_controller_spec.rb +++ b/spec/controllers/users/rules_of_use_controller_spec.rb @@ -33,7 +33,7 @@ it 'logs an analytics event for visiting' do stub_analytics - expect(@analytics).to receive(:track_event).with(Analytics::RULES_OF_USE_VISIT) + expect(@analytics).to receive(:track_event).with('Rules of Use Visited') action end @@ -64,7 +64,7 @@ it 'logs an analytics event for visiting' do stub_analytics - expect(@analytics).to receive(:track_event).with(Analytics::RULES_OF_USE_VISIT) + expect(@analytics).to receive(:track_event).with('Rules of Use Visited') action end @@ -117,7 +117,7 @@ it 'logs a successful analytics event' do stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::RULES_OF_USE_SUBMITTED, hash_including(success: true)) + with('Rules of Use Submitted', hash_including(success: true)) action end @@ -147,7 +147,7 @@ it 'logs a failure analytics event' do stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::RULES_OF_USE_SUBMITTED, hash_including(success: false)) + with('Rules of Use Submitted', hash_including(success: false)) action end diff --git a/spec/controllers/users/service_provider_revoke_controller_spec.rb b/spec/controllers/users/service_provider_revoke_controller_spec.rb index c7049737bda..e72982aef8a 100644 --- a/spec/controllers/users/service_provider_revoke_controller_spec.rb +++ b/spec/controllers/users/service_provider_revoke_controller_spec.rb @@ -32,7 +32,7 @@ it 'logs an analytics event for visiting' do stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::SP_REVOKE_CONSENT_VISITED, issuer: service_provider.issuer) + with('SP Revoke Consent: Visited', issuer: service_provider.issuer) subject end @@ -75,7 +75,7 @@ it 'logs an analytics event for revoking' do stub_analytics expect(@analytics).to receive(:track_event). - with(Analytics::SP_REVOKE_CONSENT_REVOKED, issuer: service_provider.issuer) + with('SP Revoke Consent: Revoked', issuer: service_provider.issuer) subject end diff --git a/spec/features/idv/doc_auth/document_capture_step_spec.rb b/spec/features/idv/doc_auth/document_capture_step_spec.rb index ff7a7072623..8bb5cc04117 100644 --- a/spec/features/idv/doc_auth/document_capture_step_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_step_spec.rb @@ -41,7 +41,7 @@ within_window new_window do expect(fake_analytics).to have_logged_event( - Analytics::RETURN_TO_SP_FAILURE_TO_PROOF, + 'Return to SP: Failed to proof', step: 'document_capture', location: 'document_capture_troubleshooting_options', ) diff --git a/spec/features/idv/doc_auth/welcome_step_spec.rb b/spec/features/idv/doc_auth/welcome_step_spec.rb index 962d253b052..d0dde031e89 100644 --- a/spec/features/idv/doc_auth/welcome_step_spec.rb +++ b/spec/features/idv/doc_auth/welcome_step_spec.rb @@ -28,7 +28,7 @@ def expect_doc_auth_upload_step click_on t('idv.troubleshooting.options.get_help_at_sp', sp_name: sp_name) expect(fake_analytics).to have_logged_event( - Analytics::RETURN_TO_SP_FAILURE_TO_PROOF, + 'Return to SP: Failed to proof', step: 'welcome', location: 'missing_items', ) @@ -38,7 +38,7 @@ def expect_doc_auth_upload_step click_on t('idv.troubleshooting.options.supported_documents') expect(fake_analytics).to have_logged_event( - Analytics::EXTERNAL_REDIRECT, + 'External Redirect', step: 'welcome', location: 'missing_items', flow: 'idv', @@ -53,7 +53,7 @@ def expect_doc_auth_upload_step click_on t('idv.troubleshooting.options.learn_more_address_verification_options') expect(fake_analytics).to have_logged_event( - Analytics::EXTERNAL_REDIRECT, + 'External Redirect', step: 'welcome', location: 'missing_items', flow: 'idv', @@ -90,7 +90,7 @@ def expect_doc_auth_upload_step click_on "‹ #{t('links.back_to_sp', sp: sp_name)}" expect(fake_analytics).to have_logged_event( - Analytics::RETURN_TO_SP_FAILURE_TO_PROOF, + 'Return to SP: Failed to proof', step: 'welcome', location: 'cancel', ) diff --git a/spec/features/idv/doc_capture/document_capture_step_spec.rb b/spec/features/idv/doc_capture/document_capture_step_spec.rb index e1c5ee6da59..e27a594a712 100644 --- a/spec/features/idv/doc_capture/document_capture_step_spec.rb +++ b/spec/features/idv/doc_capture/document_capture_step_spec.rb @@ -154,7 +154,7 @@ within_window new_window do expect(fake_analytics).to have_logged_event( - Analytics::RETURN_TO_SP_FAILURE_TO_PROOF, + 'Return to SP: Failed to proof', step: 'document_capture', location: 'document_capture_troubleshooting_options', ) diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 797c7b32f34..3b8ae651141 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -218,7 +218,7 @@ scenario 'user can see and use password visibility toggle', js: true do visit new_user_session_path - check t('forms.passwords.show') + check t('components.password_toggle.toggle_label') expect(page).to have_css('input.password[type="text"]') end diff --git a/spec/features/users/user_profile_spec.rb b/spec/features/users/user_profile_spec.rb index c7d6ea1ebfa..1e598c120ed 100644 --- a/spec/features/users/user_profile_spec.rb +++ b/spec/features/users/user_profile_spec.rb @@ -121,7 +121,7 @@ fill_in t('forms.passwords.edit.labels.password'), with: 'this is a great sentence' expect(page).to have_content 'Great' - check t('forms.passwords.show') + check t('components.password_toggle.toggle_label') expect(page).to_not have_css('input.password[type="password"]') expect(page).to have_css('input.password[type="text"]') diff --git a/spec/features/visitors/set_password_spec.rb b/spec/features/visitors/set_password_spec.rb index fa5c5187129..9cffa3706a7 100644 --- a/spec/features/visitors/set_password_spec.rb +++ b/spec/features/visitors/set_password_spec.rb @@ -65,7 +65,7 @@ expect(page).to have_css('input.password[type="password"]') - check t('forms.passwords.show') + check t('components.password_toggle.toggle_label') expect(page).to_not have_css('input.password[type="password"]') expect(page).to have_css('input.password[type="text"]') diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index 0de8b7024e4..a19f6c95957 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -16,8 +16,9 @@ class BaseTask ALLOWED_UNTRANSLATED_KEYS = [ { key: 'account.navigation.menu', locales: %i[fr] }, # "Menu" is "Menu" in French { key: 'doc_auth.headings.photo', locales: %i[fr] }, # "Photo" is "Photo" in French - { key: 'errors.alt.error', locales: %i[es] }, # "Error" is "Error" in Spanish { key: 'components.status_page.icons.error', locales: %i[es] }, # "Error" is "Error" in Spanish + { key: 'components.status_page.icons.question', locales: %i[fr] }, # "Question" is "Question" in French + { key: 'errors.alt.error', locales: %i[es] }, # "Error" is "Error" in Spanish { key: /^i18n\.locale\./ }, # Show locale options translated as that language { key: /^countries/ }, # Some countries have the same name across languages { key: 'links.contact', locales: %i[fr] }, # "Contact" is "Contact" in French diff --git a/spec/javascripts/packages/document-capture/components/form-error-message-spec.jsx b/spec/javascripts/packages/document-capture/components/form-error-message-spec.jsx deleted file mode 100644 index b9ab3346bcd..00000000000 --- a/spec/javascripts/packages/document-capture/components/form-error-message-spec.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import { I18nContext } from '@18f/identity-react-i18n'; -import FormErrorMessage, { - RequiredValueMissingError, - CameraAccessDeclinedError, -} from '@18f/identity-document-capture/components/form-error-message'; -import { UploadFormEntryError } from '@18f/identity-document-capture/services/upload'; -import { BackgroundEncryptedUploadError } from '@18f/identity-document-capture/higher-order/with-background-encrypted-upload'; -import { render } from '../../../support/document-capture'; - -describe('document-capture/components/form-error-message', () => { - it('returns formatted RequiredValueMissingError', () => { - const { getByText } = render(); - - expect(getByText('simple_form.required.text')).to.be.ok(); - }); - - it('returns formatted UploadFormEntryError', () => { - const { getByText } = render( - , - ); - - expect(getByText('Field is required')).to.be.ok(); - }); - - it('returns formatted BackgroundEncryptedUploadError', () => { - const { getByText } = render( - - - , - ); - - const message = getByText('Sorry, something went wrong on our end. Please try again.'); - expect(message).to.be.ok(); - expect(message.innerHTML.split(' ')).to.deep.equal([ - 'Sorry, something went wrong on our end. Please', - 'try', - 'again.', - ]); - }); - - it('returns formatted CameraAccessDeclinedError', () => { - const { getByText } = render( - - - , - ); - - expect(getByText('Your camera is blocked')).to.be.ok(); - }); - - it('returns null if error is of an unknown type', () => { - const { container } = render(); - - expect(container.childNodes).to.be.empty(); - }); - - describe('isDetail', () => { - it('returns formatted CameraAccessDeclinedError', () => { - const { getByText } = render( - - - , - ); - - expect(getByText('We don’t have permission', { exact: false })).to.be.ok(); - }); - }); -}); diff --git a/spec/jobs/reports/total_ial2_costs_report_spec.rb b/spec/jobs/reports/total_ial2_costs_report_spec.rb new file mode 100644 index 00000000000..751c32fc192 --- /dev/null +++ b/spec/jobs/reports/total_ial2_costs_report_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +RSpec.describe Reports::TotalIal2CostsReport do + subject(:report) { described_class.new } + + describe '#perform' do + let(:issuer1) { 'issuer1' } + let(:issuer2) { 'issuer2' } + + let!(:sp1) { create(:service_provider, issuer: issuer1, friendly_name: issuer1) } + let!(:sp2) { create(:service_provider, issuer: issuer2, friendly_name: issuer2) } + + let(:date) { Date.new(2022, 4, 1) } + let(:yesterday) { Date.new(2022, 3, 31) } + let(:yesterday_utc) { yesterday.in_time_zone('UTC') } + let(:too_old) { Date.new(2021, 12, 31) } + + before do + SpCost.create( + agency_id: 1, + issuer: issuer1, + cost_type: 'authentication', + created_at: yesterday_utc, + ial: 2, + ) + SpCost.create( + agency_id: 2, + issuer: issuer2, + cost_type: 'authentication', + created_at: yesterday_utc, + ial: 2, + ) + + SpCost.create( + agency_id: 1, issuer: issuer1, cost_type: 'sms', created_at: yesterday_utc, ial: 2, + ) + + # rows that get ignored + SpCost.create( + agency_id: 2, issuer: issuer2, cost_type: 'user_added', created_at: too_old, ial: 2, + ) + SpCost.create( + agency_id: 2, issuer: issuer2, cost_type: 'user_added', created_at: yesterday_utc, ial: 1, + ) + end + + it 'writes a CSV report to S3' do + expect(report).to receive(:save_report) do |report_name, body, extension:| + expect(report_name).to eq(described_class::REPORT_NAME) + expect(extension).to eq('csv') + + csv = CSV.parse(body, headers: true) + expect(csv.length).to eq(2) + + row = csv.first + expect(row['date']).to eq(yesterday.to_s) + expect(row['cost_type']).to eq('authentication') + expect(row['ial']).to eq(2.to_s) + expect(row['count']).to eq(2.to_s) + + row = csv[1] + expect(row['date']).to eq(yesterday.to_s) + expect(row['cost_type']).to eq('sms') + expect(row['ial']).to eq(2.to_s) + expect(row['count']).to eq(1.to_s) + end + + report.perform(date) + end + end + + describe '#good_job_concurrency_key' do + let(:date) { Time.zone.today } + + it 'is the job name and the date' do + job = described_class.new(date) + expect(job.good_job_concurrency_key). + to eq("#{described_class::REPORT_NAME}-#{date}") + end + end +end diff --git a/spec/services/db/sp_return_log_spec.rb b/spec/services/db/sp_return_log_spec.rb index 7d730dfbae6..610b26e457b 100644 --- a/spec/services/db/sp_return_log_spec.rb +++ b/spec/services/db/sp_return_log_spec.rb @@ -2,8 +2,7 @@ describe Db::SpReturnLog do describe '#create_return' do - # Can be removed after deploy of RC 185 - it 'updates return log if it already exists' do + it 'does not fail if row already exists' do sp_return_log = SpReturnLog.create( request_id: SecureRandom.uuid, user_id: 1, @@ -12,6 +11,7 @@ issuer: 'example.com', requested_at: Time.zone.now, ) + Db::SpReturnLog.create_return( request_id: sp_return_log.request_id, user_id: sp_return_log.user_id, @@ -20,7 +20,8 @@ issuer: sp_return_log.issuer, requested_at: sp_return_log.requested_at, ) - expect(sp_return_log.reload.returned_at).to_not be_nil + + expect(SpReturnLog.count).to eq 1 end end end diff --git a/tsconfig.json b/tsconfig.json index ec4f01ebef0..13ddedd227d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "strictNullChecks": true, "jsx": "react-jsx", "esModuleInterop": true, + "experimentalDecorators": true, "moduleResolution": "node", "module": "ESNext", "target": "ESNext", diff --git a/yarn.lock b/yarn.lock index 306533df381..bf159f2e42e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -82,6 +82,19 @@ "@babel/helper-replace-supers" "^7.16.7" "@babel/helper-split-export-declaration" "^7.16.7" +"@babel/helper-create-class-features-plugin@^7.17.1": + version "7.17.6" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.6.tgz#3778c1ed09a7f3e65e6d6e0f6fbfcc53809d92c9" + integrity sha512-SogLLSxXm2OkBbSsHZMM4tUi8fUzjs63AT/d0YQIzr6GSd8Hxsbk2KYDX0k0DweAzGMj/YWeiCsorIdtdcW8Eg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.16.7" + "@babel/helper-environment-visitor" "^7.16.7" + "@babel/helper-function-name" "^7.16.7" + "@babel/helper-member-expression-to-functions" "^7.16.7" + "@babel/helper-optimise-call-expression" "^7.16.7" + "@babel/helper-replace-supers" "^7.16.7" + "@babel/helper-split-export-declaration" "^7.16.7" + "@babel/helper-create-regexp-features-plugin@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.14.5.tgz#c7d5ac5e9cf621c26057722fb7a8a4c5889358c4" @@ -314,6 +327,17 @@ "@babel/helper-plugin-utils" "^7.14.5" "@babel/plugin-syntax-class-static-block" "^7.14.5" +"@babel/plugin-proposal-decorators@^7.17.2": + version "7.17.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.17.2.tgz#c36372ddfe0360cac1ee331a238310bddca11493" + integrity sha512-WH8Z95CwTq/W8rFbMqb9p3hicpt4RX4f0K659ax2VHxgOyT6qQmUaEVEjIh4WR9Eh9NymkVn5vwsrE68fAQNUw== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.17.1" + "@babel/helper-plugin-utils" "^7.16.7" + "@babel/helper-replace-supers" "^7.16.7" + "@babel/plugin-syntax-decorators" "^7.17.0" + charcodes "^0.2.0" + "@babel/plugin-proposal-dynamic-import@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.14.5.tgz#0c6617df461c0c1f8fff3b47cd59772360101d2c" @@ -437,6 +461,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.14.5" +"@babel/plugin-syntax-decorators@^7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.17.0.tgz#a2be3b2c9fe7d78bd4994e790896bc411e2f166d" + integrity sha512-qWe85yCXsvDEluNP0OyeQjH63DlhAR3W7K9BxxU1MvbDb48tgBG+Ao6IJJ6smPDrrVzSQZrbF6donpkFBMcs3A== + dependencies: + "@babel/helper-plugin-utils" "^7.16.7" + "@babel/plugin-syntax-dynamic-import@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" @@ -1394,7 +1425,15 @@ "@types/mime" "^1" "@types/node" "*" -"@types/sinon@^10.0.11": +"@types/sinon-chai@^3.2.8": + version "3.2.8" + resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc" + integrity sha512-d4ImIQbT/rKMG8+AXpmcan5T2/PNeSjrYhvkwet6z0p8kzYtfgA32xzOBlbU0yqJfq+/0Ml805iFoODO0LP5/g== + dependencies: + "@types/chai" "*" + "@types/sinon" "*" + +"@types/sinon@*", "@types/sinon@^10.0.11": version "10.0.11" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.11.tgz#8245827b05d3fc57a6601bd35aee1f7ad330fc42" integrity sha512-dmZsHlBsKUtBpHriNjlK0ndlvEh8dcb9uV9Afsbt89QIyydpC7NcR+nWlAhASfy3GHnxTl4FX/aKE7XZUt/B4g== @@ -2198,6 +2237,11 @@ chalk@^4.0, chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +charcodes@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/charcodes/-/charcodes-0.2.0.tgz#5208d327e6cc05f99eb80ffc814707572d1f14e4" + integrity sha512-Y4kiDb+AM4Ecy58YkuZrrSRJBDQdQ2L+NyS1vHHFtNtUjgutcZfx3yp1dAONI/oPaPmyGfCLx5CxL+zauIMyKQ== + check-error@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"