diff --git a/.rubocop.yml b/.rubocop.yml index c04b6c3133b..b949a8fc0e2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -74,7 +74,6 @@ IdentityIdp/ImageSizeLinter: - app/views/shared/_nav_branded.html.erb - app/views/sign_up/completions/show.html.erb - app/views/users/two_factor_authentication_setup/index.html.erb - - app/views/users/webauthn_setup/new.html.erb IdentityIdp/RedirectBackLinter: Enabled: true @@ -1218,6 +1217,10 @@ Style/MultilineMemoization: Style/MultilineWhenThen: Enabled: true +Style/MutableConstant: + Enabled: true + EnforcedStyle: strict + Style/NegatedWhile: Enabled: true diff --git a/Gemfile b/Gemfile index c89b14189e1..72c0c7bdf82 100644 --- a/Gemfile +++ b/Gemfile @@ -65,12 +65,12 @@ gem 'redacted_struct' gem 'redis', '>= 3.2.0' gem 'redis-session-store', github: '18F/redis-session-store', tag: 'v1.0.1-18f' gem 'retries' -gem 'rotp', '~> 6.1' +gem 'rotp', '~> 6.3', '>= 6.3.0' gem 'rqrcode' gem 'ruby-progressbar' gem 'ruby-saml' gem 'safe_target_blank', '>= 1.0.2' -gem 'saml_idp', github: '18F/saml_idp', tag: '0.19.2-18f' +gem 'saml_idp', github: '18F/saml_idp', tag: '0.19.3-18f' gem 'scrypt' gem 'simple_form', '>= 5.0.2' gem 'stringex', require: false @@ -115,7 +115,7 @@ group :development, :test do gem 'psych' gem 'rspec', '~> 3.12.0' gem 'rspec-rails', '~> 6.0' - gem 'rubocop', '~> 1.59.0', require: false + gem 'rubocop', '~> 1.62.0', require: false gem 'rubocop-performance', '~> 1.20.2', require: false gem 'rubocop-rails', '>= 2.5.2', require: false gem 'rubocop-rspec', require: false diff --git a/Gemfile.lock b/Gemfile.lock index d0258b2095f..1b99491b670 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -34,10 +34,10 @@ GIT GIT remote: https://github.com/18F/saml_idp.git - revision: 4c858dab80cfe32081a7e6bd7cd76c43cc3ec778 - tag: 0.19.2-18f + revision: 95369fdd9336773b9983c8de71eb35a8c92e9683 + tag: 0.19.3-18f specs: - saml_idp (0.19.2.pre.18f) + saml_idp (0.19.3.pre.18f) activesupport builder faraday @@ -199,10 +199,10 @@ GEM aws-sigv4 (~> 1.1) aws-sigv4 (1.8.0) aws-eventstream (~> 1, >= 1.0.2) - axe-core-api (4.7.0) + axe-core-api (4.9.0) dumb_delegator virtus - axe-core-rspec (4.7.0) + axe-core-rspec (4.9.0) axe-core-api dumb_delegator virtus @@ -448,7 +448,7 @@ GEM net-ssh (6.1.0) newrelic_rpm (9.7.0) nio4r (2.7.0) - nokogiri (1.16.2) + nokogiri (1.16.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) openssl (3.0.2) @@ -456,7 +456,7 @@ GEM openssl (> 2.0, < 3.1) orm_adapter (0.5.0) parallel (1.24.0) - parser (3.3.0.0) + parser (3.3.0.5) ast (~> 2.4.1) racc pg (1.5.4) @@ -580,7 +580,7 @@ GEM rgeo-activerecord (7.0.1) activerecord (>= 5.0) rgeo (>= 1.0.0) - rotp (6.2.0) + rotp (6.3.0) rouge (4.2.0) rqrcode (2.1.0) chunky_png (~> 1.0) @@ -611,19 +611,19 @@ GEM rspec-support (3.12.1) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.59.0) + rubocop (1.62.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.2.4) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.30.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) + rubocop-ast (1.31.2) + parser (>= 3.3.0.4) rubocop-capybara (2.19.0) rubocop (~> 1.41) rubocop-factory_bot (2.24.0) @@ -836,13 +836,13 @@ DEPENDENCIES redis (>= 3.2.0) redis-session-store! retries - rotp (~> 6.1) + rotp (~> 6.3, >= 6.3.0) rqrcode rspec (~> 3.12.0) rspec-rails (~> 6.0) rspec-retry rspec_junit_formatter - rubocop (~> 1.59.0) + rubocop (~> 1.62.0) rubocop-performance (~> 1.20.2) rubocop-rails (>= 2.5.2) rubocop-rspec diff --git a/app/assets/images/mfa-options/security_key.svg b/app/assets/images/mfa-options/security_key.svg new file mode 100644 index 00000000000..1f84f3fe32b --- /dev/null +++ b/app/assets/images/mfa-options/security_key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/mfa-options/security_key_mobile.svg b/app/assets/images/mfa-options/security_key_mobile.svg new file mode 100644 index 00000000000..e617759ddce --- /dev/null +++ b/app/assets/images/mfa-options/security_key_mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/security-key.svg b/app/assets/images/security-key.svg deleted file mode 100644 index 9d305f7f6d0..00000000000 --- a/app/assets/images/security-key.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/stylesheets/email.css.scss b/app/assets/stylesheets/email.css.scss index 2015a1661d0..38d0dfc7a89 100644 --- a/app/assets/stylesheets/email.css.scss +++ b/app/assets/stylesheets/email.css.scss @@ -1,4 +1,7 @@ -@use 'uswds-core' as *; +@use 'uswds-core' as * with ( + $theme-table-border-color: 'base-lighter', + $theme-table-header-background-color: 'base-lightest' +); @use 'variables/app' as *; @use 'variables/email' as *; @@ -198,6 +201,10 @@ h6 { @include u-font('sans', 'md'); } +.font-family-mono { + font-family: monospace; +} + .margin-bottom-0 { @include u-margin-bottom(0); } @@ -269,3 +276,30 @@ h6 { @extend %usa-list-item; } } + +.usa-table { + @include usa-table; + + border-collapse: separate; + border-spacing: 0; + + tbody td { + border-top: 0; + } + + thead th:first-child { + border-top-left-radius: units(0.5); + } + + thead th:last-child { + border-top-right-radius: units(0.5); + } + + tbody tr:last-child td:first-child { + border-bottom-left-radius: units(0.5); + } + + tbody tr:last-child td:last-child { + border-bottom-right-radius: units(0.5); + } +} diff --git a/app/components/README.md b/app/components/README.md index 8bc284f53a5..5a6cd4cfffe 100644 --- a/app/components/README.md +++ b/app/components/README.md @@ -1,13 +1,22 @@ # Components -This folder contains a collection of components implemented using the [ViewComponent gem](https://viewcomponent.org/). Components are reusable user interface elements, and usually comprise of a component class and ERB template. They are similar to partials in many of their use-cases, but have a few advantages in the form of class-based presenter logic, testability, performance, and conveniences for loading JavaScript assets. +This folder contains a collection of components implemented using the [ViewComponent gem](https://viewcomponent.org/). Components are reusable user interface elements, and usually comprise of a component class and ERB template. They are similar to partials in many of their use-cases, but have a few advantages in the form of class-based presenter logic, testability, performance, and conveniences for loading accompanying script and style assets. -Each component must implement a class extending the `BaseComponent` class. If an accompanying `.html.erb` file exists, it will be used as the template for that component. Similarly, if an `.ts` or `.tsx` file exists, the bundle generated from that JavaScript will be loaded automatically any time the component is rendered. +Each component must implement a class extending the `BaseComponent` class. + +Optional files: + +- `.html.erb`: Used as the template for the component +- `.ts`: A corresponding JavaScript bundle will be created and loaded automatically any time the component is rendered +- `.scss`: A corresponding CSS stylesheet will be created and loaded automatically any time the component is rendered + +Example: ``` components/ ├─ example_component.rb ├─ example_component.html.erb +├─ example_component.scss └─ example_component.ts ``` diff --git a/app/components/alert_icon_component.rb b/app/components/alert_icon_component.rb index 74b146c6c83..c85c53db461 100644 --- a/app/components/alert_icon_component.rb +++ b/app/components/alert_icon_component.rb @@ -8,7 +8,7 @@ class AlertIconComponent < BaseComponent personal_key: 'status/personal-key.svg', info_question: 'status/info-question.svg', delete: 'status/delete.svg', - } + }.freeze DEFAULT_WIDTH = 88 DEFAULT_HEIGHT = 88 diff --git a/app/components/one_time_code_input_component.rb b/app/components/one_time_code_input_component.rb index a0bbbc2c57f..a3247b49c19 100644 --- a/app/components/one_time_code_input_component.rb +++ b/app/components/one_time_code_input_component.rb @@ -16,7 +16,7 @@ class OneTimeCodeInputComponent < BaseComponent alias_method :autofocus?, :autofocus # @see https://tc39.es/ecma262/#prod-SyntaxCharacter - JS_REGEXP_SYNTAX_CHARACTER = Regexp.union(%w[^ $ \ . * + ? ( ) [ ] { } |]) + JS_REGEXP_SYNTAX_CHARACTER = Regexp.union(%w[^ $ \ . * + ? ( ) [ ] { } |]).freeze # @param [FormBuilder] form Form builder instance. # @param [Symbol] name Field name. Defaults to `:code`. diff --git a/app/components/security_key_image_component.rb b/app/components/security_key_image_component.rb new file mode 100644 index 00000000000..cefb2a981c3 --- /dev/null +++ b/app/components/security_key_image_component.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class SecurityKeyImageComponent < BaseComponent + attr_reader :tag_options + + def initialize(mobile:, **tag_options) + @mobile = mobile + @tag_options = tag_options + end + + def mobile? + !!@mobile + end + + def call + # rubocop:disable Rails/OutputSafety + Nokogiri::HTML5.fragment(read_svg).tap do |doc| + doc.at_css('svg').tap do |svg| + svg[:class] = css_class + svg[:role] = 'img' + + tag_options.except(:class, :data, :aria).each do |key, value| + svg[key] = value + end + [:data, :aria].each do |prefix| + tag_options[prefix]&.each do |key, value| + svg[:"#{prefix}-#{key}"] = value + end + end + + svg << "#{title}" + end + end.to_s.html_safe + # rubocop:enable Rails/OutputSafety + end + + def css_class + [ + 'width-full', + 'height-auto', + mobile? && 'security-key--mobile', + *tag_options[:class], + ].select(&:present?).join(' ') + end + + def title + mobile? ? + t('forms.webauthn_setup.step_2_image_mobile_alt') : + t('forms.webauthn_setup.step_2_image_alt') + end + + def read_svg + Rails.root.join( + 'app', 'assets', 'images', 'mfa-options', + (mobile? ? 'security_key_mobile.svg' : 'security_key.svg') + ).read + end +end diff --git a/app/components/security_key_image_component.scss b/app/components/security_key_image_component.scss new file mode 100644 index 00000000000..654f2a41d8a --- /dev/null +++ b/app/components/security_key_image_component.scss @@ -0,0 +1,97 @@ +.security-key-image__key { + animation: security-key-image__key__move 4s ease-in-out infinite; + + @media (prefers-reduced-motion) { + animation: none; + } +} + +.security-key-image__arrow { + animation: security-key-image__arrow__move 4s ease-in-out infinite; + + @media (prefers-reduced-motion) { + animation: none; + } +} + +.security-key--mobile { + .security-key-image__key { + animation-name: security-key-image__key__move__mobile; + } + + .security-key-image__arrow { + animation-name: security-key-image__arrow__move__mobile; + } +} + +@keyframes security-key-image__key__move { + 25% { + transform: translate(0, 0); + } + 37.5% { + transform: translate(-88, 0); + } + 87.5% { + transform: translate(-88, 0); + } + 100% { + transform: translate(0, 0); + } +} + +@keyframes security-key-image__key__move__mobile { + 25% { + transform: translate(0, 0); + } + 45% { + transform: translate(0, -76); + } + 75% { + transform: translate(0, -76); + } + 87.5% { + transform: translate(0, 0); + } +} + +@keyframes security-key-image__arrow__move { + 7.5% { + transform: translate(0, 0); + } + 15% { + opacity: 1; + } + 25% { + opacity: 0; + transform: translate(-10, 0); + } + 92.5% { + opacity: 0; + transform: translate(-10, 0); + } + 100% { + opacity: 1; + transform: translate(0, 0); + } +} + +@keyframes security-key-image__arrow__move__mobile { + 7.5% { + transform: translate(0, 0); + } + 15% { + opacity: 1; + } + 25% { + opacity: 0; + transform: translate(0, -8); + } + 87.5% { + opacity: 0; + transform: translate(0, -8); + } + 100% { + opacity: 1; + transform: translate(0, 0); + } +} diff --git a/app/components/spinner_button_component.rb b/app/components/spinner_button_component.rb index 64beda14bb8..b536f5c92f6 100644 --- a/app/components/spinner_button_component.rb +++ b/app/components/spinner_button_component.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SpinnerButtonComponent < BaseComponent - DEFAULT_LONG_WAIT_DURATION = 15.seconds + DEFAULT_LONG_WAIT_DURATION = 15.seconds.freeze attr_reader :action_message, :button_options, diff --git a/app/controllers/analytics_events_controller.rb b/app/controllers/analytics_events_controller.rb index fb24dd24e01..55a2070d69d 100644 --- a/app/controllers/analytics_events_controller.rb +++ b/app/controllers/analytics_events_controller.rb @@ -6,7 +6,7 @@ class AnalyticsEventsController < ApplicationController prepend_before_action :skip_session_expiration skip_before_action :disable_caching - JSON_FILE = Rails.public_path.join('api', '_analytics-events.json') + JSON_FILE = Rails.public_path.join('api', '_analytics-events.json').freeze def index if File.exist?(JSON_FILE) diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb index 90b46dbe4e0..31ae84c059c 100644 --- a/app/controllers/concerns/two_factor_authenticatable.rb +++ b/app/controllers/concerns/two_factor_authenticatable.rb @@ -9,7 +9,7 @@ module TwoFactorAuthenticatable DIRECT_OTP_LENGTH = 6 PROOFING_DIRECT_OTP_LENGTH = 6 ALLOWED_OTP_DRIFT_SECONDS = 30 - DIRECT_OTP_VALID_FOR_MINUTES = IdentityConfig.store.otp_valid_for + DIRECT_OTP_VALID_FOR_MINUTES = IdentityConfig.store.otp_valid_for.freeze DIRECT_OTP_VALID_FOR_SECONDS = DIRECT_OTP_VALID_FOR_MINUTES * 60 REMEMBER_2FA_COOKIE = 'remember_tfa' diff --git a/app/controllers/concerns/two_factor_authenticatable_methods.rb b/app/controllers/concerns/two_factor_authenticatable_methods.rb index c0b778b283a..a7e37560ef0 100644 --- a/app/controllers/concerns/two_factor_authenticatable_methods.rb +++ b/app/controllers/concerns/two_factor_authenticatable_methods.rb @@ -100,6 +100,10 @@ def handle_remember_device_preference(remember_device_preference) # You can pass in any "type" with a corresponding I18n key in # two_factor_authentication.invalid_#{type} def handle_invalid_otp(type:, context: nil) + if context == UserSessionContext::AUTHENTICATION_CONTEXT + handle_invalid_verification_for_authentication_context + end + update_invalid_user flash.now[:error] = invalid_otp_error(type) @@ -150,6 +154,10 @@ def update_invalid_user current_user.increment_second_factor_attempts_count! end + def handle_invalid_verification_for_authentication_context + create_user_event(:sign_in_unsuccessful_2fa) + end + def handle_valid_verification_for_confirmation_context(auth_method:) mark_user_session_authenticated(auth_method:, authentication_type: :valid_2fa_confirmation) reset_second_factor_attempts_count diff --git a/app/controllers/idv/please_call_controller.rb b/app/controllers/idv/please_call_controller.rb index 5b3eb66be19..00c027b2cb1 100644 --- a/app/controllers/idv/please_call_controller.rb +++ b/app/controllers/idv/please_call_controller.rb @@ -9,7 +9,7 @@ class PleaseCallController < ApplicationController before_action :handle_fraud_rejection before_action :confirm_fraud_pending - FRAUD_REVIEW_CONTACT_WITHIN_DAYS = 14.days + FRAUD_REVIEW_CONTACT_WITHIN_DAYS = 14.days.freeze def show analytics.idv_please_call_visited diff --git a/app/controllers/idv/unavailable_controller.rb b/app/controllers/idv/unavailable_controller.rb index a3fba15759b..2c546fdbd7c 100644 --- a/app/controllers/idv/unavailable_controller.rb +++ b/app/controllers/idv/unavailable_controller.rb @@ -2,7 +2,7 @@ module Idv class UnavailableController < ApplicationController - ALLOWED_FROM_LOCATIONS = [SignUp::RegistrationsController::CREATE_ACCOUNT] + ALLOWED_FROM_LOCATIONS = [SignUp::RegistrationsController::CREATE_ACCOUNT].freeze before_action :redirect_if_idv_available_and_from_create_account diff --git a/app/controllers/two_factor_authentication/backup_code_verification_controller.rb b/app/controllers/two_factor_authentication/backup_code_verification_controller.rb index 19ad610ec59..4e8c05c55e3 100644 --- a/app/controllers/two_factor_authentication/backup_code_verification_controller.rb +++ b/app/controllers/two_factor_authentication/backup_code_verification_controller.rb @@ -52,6 +52,7 @@ def presenter_for_two_factor_authentication_method end def handle_invalid_backup_code + handle_invalid_verification_for_authentication_context update_invalid_user flash.now[:error] = t('two_factor_authentication.invalid_backup_code') diff --git a/app/controllers/two_factor_authentication/webauthn_verification_controller.rb b/app/controllers/two_factor_authentication/webauthn_verification_controller.rb index 9ef3e53ed32..57072bccfc6 100644 --- a/app/controllers/two_factor_authentication/webauthn_verification_controller.rb +++ b/app/controllers/two_factor_authentication/webauthn_verification_controller.rb @@ -70,6 +70,7 @@ def handle_valid_webauthn end def handle_invalid_webauthn(result) + handle_invalid_verification_for_authentication_context flash[:error] = result.first_error_message if platform_authenticator? diff --git a/app/controllers/users/piv_cac_login_controller.rb b/app/controllers/users/piv_cac_login_controller.rb index 7daf4dc762d..13fb27a9ad0 100644 --- a/app/controllers/users/piv_cac_login_controller.rb +++ b/app/controllers/users/piv_cac_login_controller.rb @@ -91,6 +91,7 @@ def next_step end def process_invalid_submission + handle_invalid_verification_for_authentication_context session[:needs_to_setup_piv_cac_after_sign_in] = true if piv_cac_login_form.valid_token? process_token_with_error diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 2daffe146ad..bcdc8188813 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -188,6 +188,10 @@ def handle_valid_otp_params(otp_delivery_selection_result, method, default = nil context: context, ) end + + if exceeded_short_term_otp_rate_limit? + return handle_too_many_short_term_otp_sends(method: method, default: default) + end return handle_too_many_confirmation_sends if exceeded_phone_confirmation_limit? @telephony_result = send_user_otp(method) @@ -263,6 +267,19 @@ def phone_confirmation_rate_limiter ) end + def short_term_otp_rate_limiter + @short_term_otp_rate_limiter ||= RateLimiter.new( + user: current_user, + rate_limit_type: :short_term_phone_otp, + ) + end + + def exceeded_short_term_otp_rate_limit? + return false unless IdentityConfig.store.short_term_phone_otp_rate_limiter_enabled + short_term_otp_rate_limiter.increment! + short_term_otp_rate_limiter.limited? + end + def exceeded_phone_confirmation_limit? return false unless UserSessionContext.confirmation_context?(context) phone_confirmation_rate_limiter.increment! @@ -346,7 +363,36 @@ def webauthn_params { platform: current_user.webauthn_configurations.platform_authenticators.present? } end + def handle_too_many_short_term_otp_sends(method:, default:) + analytics.rate_limit_reached( + limiter_type: short_term_otp_rate_limiter.rate_limit_type, + country_code: parsed_phone.country, + phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), + context: context, + otp_delivery_preference: method, + ) + + flash[:error] = t( + 'errors.messages.phone_confirmation_limited', + timeout: distance_of_time_in_words( + Time.zone.now, + [short_term_otp_rate_limiter.expires_at, Time.zone.now].compact.max, + ), + ) + + redirect_to login_two_factor_url( + otp_delivery_preference: method, + otp_make_default_number: default, + ) + end + def handle_too_many_confirmation_sends + analytics.rate_limit_reached( + limiter_type: phone_confirmation_rate_limiter.rate_limit_type, + country_code: parsed_phone.country, + phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), + ) + flash[:error] = t( 'errors.messages.phone_confirmation_limited', timeout: distance_of_time_in_words( diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index 31ccc66063b..4f633ead285 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -15,6 +15,7 @@ class WebauthnSetupController < ApplicationController before_action :validate_existing_platform_authenticator helper_method :in_multi_mfa_selection_flow? + helper_method :mobile? def new form = WebauthnVisitForm.new( diff --git a/app/forms/idv/doc_pii_form.rb b/app/forms/idv/doc_pii_form.rb index b37946160a2..6b62481c092 100644 --- a/app/forms/idv/doc_pii_form.rb +++ b/app/forms/idv/doc_pii_form.rb @@ -78,7 +78,8 @@ def self.present_error(existing_errors) private - PII_ERROR_KEYS = %i[name dob address1 state zipcode jurisdiction state_id_number dob_min_age] + PII_ERROR_KEYS = %i[name dob address1 state zipcode jurisdiction state_id_number + dob_min_age].freeze attr_reader :pii_from_doc diff --git a/app/forms/openid_connect_token_form.rb b/app/forms/openid_connect_token_form.rb index d9af0890798..404887ba55e 100644 --- a/app/forms/openid_connect_token_form.rb +++ b/app/forms/openid_connect_token_form.rb @@ -5,7 +5,7 @@ class OpenidConnectTokenForm include ActionView::Helpers::TranslationHelper include Rails.application.routes.url_helpers - ISSUED_AT_LEEWAY_SECONDS = 10.seconds.to_i + ISSUED_AT_LEEWAY_SECONDS = 10.seconds.to_i.freeze ATTRS = %i[ client_assertion diff --git a/app/javascript/packages/build-sass/CHANGELOG.md b/app/javascript/packages/build-sass/CHANGELOG.md index 0beb96c1c32..8f0ff3b2261 100644 --- a/app/javascript/packages/build-sass/CHANGELOG.md +++ b/app/javascript/packages/build-sass/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +### New Features + +- Add support for verbose CLI output using `--verbose` flag (`-v` shorthand), which currently outputs files being built. + +### Bug Fixes + +- Fix rebuild after error when using `--watch` mode. + ## 3.0.0 ### Breaking Changes diff --git a/app/javascript/packages/build-sass/README.md b/app/javascript/packages/build-sass/README.md index 31e1003cff3..4afdecc86a1 100644 --- a/app/javascript/packages/build-sass/README.md +++ b/app/javascript/packages/build-sass/README.md @@ -31,6 +31,7 @@ Flags: - `--out-dir`: The output directory - `--watch`: Run in watch mode, recompiling files on change - `--load-path`: Include additional path in Sass path resolution +- `--verbose` (`-v`): Output verbose debugging details ### API diff --git a/app/javascript/packages/build-sass/cli.js b/app/javascript/packages/build-sass/cli.js index c16e3871df0..79e29d4fd0b 100755 --- a/app/javascript/packages/build-sass/cli.js +++ b/app/javascript/packages/build-sass/cli.js @@ -14,6 +14,8 @@ import getErrorSassStackPaths from './get-error-sass-stack-paths.js'; /** @typedef {import('sass-embedded').Options<'sync'>} SyncSassOptions */ /** @typedef {import('sass-embedded').Exception} SassException */ /** @typedef {import('./').BuildOptions} BuildOptions */ +/** @typedef {import('node:child_process').ChildProcess} ChildProcess */ +/** @typedef {import('sass-embedded').AsyncCompiler & { process: ChildProcess}} SassAsyncCompiler */ const env = process.env.NODE_ENV || process.env.RAILS_ENV || 'development'; const isProduction = env === 'production'; @@ -24,10 +26,11 @@ const { values: flags, positionals: fileArgs } = parseArgs({ watch: { type: 'boolean' }, 'out-dir': { type: 'string' }, 'load-path': { type: 'string', multiple: true, default: [] }, + verbose: { type: 'boolean', short: 'v' }, }, }); -const { watch: isWatching, 'out-dir': outDir, 'load-path': loadPaths = [] } = flags; +const { watch: isWatching, 'out-dir': outDir, 'load-path': loadPaths = [], verbose } = flags; loadPaths.push(...getDefaultLoadPaths()); const sassCompiler = await initAsyncSassCompiler(); @@ -62,6 +65,10 @@ const isSassException = (error) => 'span' in /** @type {SassException} */ (error * @return {Promise} */ function build(files) { + if (verbose) { + console.log('Building files', files); + } + return Promise.all( files.map(async (file) => { const { loadedUrls } = await buildFile(file, options); @@ -75,7 +82,8 @@ function build(files) { console.error(error); if (isWatching && isSassException(error)) { - watchOnce(getErrorSassStackPaths(error.sassStack), () => build(files)); + const { spawnfile } = /** @type {SassAsyncCompiler} */ (sassCompiler).process; + watchOnce(getErrorSassStackPaths(error.sassStack, spawnfile), () => build(files)); } else { throw error; } diff --git a/app/javascript/packages/build-sass/get-error-sass-stack-paths.js b/app/javascript/packages/build-sass/get-error-sass-stack-paths.js index e7044fcb776..ca2aa1e2dcb 100644 --- a/app/javascript/packages/build-sass/get-error-sass-stack-paths.js +++ b/app/javascript/packages/build-sass/get-error-sass-stack-paths.js @@ -1,32 +1,30 @@ +import { dirname, relative, resolve } from 'path'; + /** * Returns all file paths contained in the given Sass stack trace. * * @example * ``` * getErrorSassStackPaths( - * 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/_functions.scss 35:8 divide()\n' + - * 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/mixins/_icon.scss 77:12 add-color-icon()\n' + - * 'app/assets/stylesheets/components/_alert.scss 13:5 @import\n' + - * 'app/assets/stylesheets/components/all.scss 3:9 @import\n' + - * 'app/assets/stylesheets/application.css.scss 7:9 root stylesheet\n', + * '../../../../app/assets/stylesheets/design-system-waiting-room.scss 31:2 @forward\n' + + * '../../../../app/assets/stylesheets/application.css.scss 4:1 root stylesheet\n', + * 'node_modules/sass-embedded-darwin-arm64/dart-sass/src/dart', * ); * // [ - * // 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/_functions.scss', - * // 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/mixins/_icon.scss', - * // 'app/assets/stylesheets/components/_alert.scss', - * // 'app/assets/stylesheets/components/all.scss', + * // 'app/assets/stylesheets/design-system-waiting-room.scss', * // 'app/assets/stylesheets/application.css.scss', * // ] * ``` * * @param {string} sassStack Sass stack trace (see example). + * @param {string} relativeFrom File from which to resolve relative paths from Sass stack trace. * * @return {string[]} Array of file paths. */ -const getErrorSassStackPaths = (sassStack) => +const getErrorSassStackPaths = (sassStack, relativeFrom) => sassStack .split(/\.scss \d+:\d+\s+.+?\n/) .filter(Boolean) - .map((basename) => `${basename}.scss`); + .map((basename) => relative('.', resolve(dirname(relativeFrom), `${basename}.scss`))); export default getErrorSassStackPaths; diff --git a/app/javascript/packages/build-sass/get-error-sass-stack-paths.spec.js b/app/javascript/packages/build-sass/get-error-sass-stack-paths.spec.js index c27615993ac..e2e040004af 100644 --- a/app/javascript/packages/build-sass/get-error-sass-stack-paths.spec.js +++ b/app/javascript/packages/build-sass/get-error-sass-stack-paths.spec.js @@ -1,39 +1,29 @@ import getErrorSassStackPaths from './get-error-sass-stack-paths.js'; describe('getErrorSassStackPaths', () => { - it('returns an array of paths from a sass stack message', () => { + it('returns an array of paths from a sass stack message resolved from relative file', () => { const stackPaths = getErrorSassStackPaths( - 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/_functions.scss 35:8 divide()\n' + - 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/mixins/_icon.scss 77:12 add-color-icon()\n' + - 'app/assets/stylesheets/components/_alert.scss 13:5 @import\n' + - 'app/assets/stylesheets/components/all.scss 3:9 @import\n' + - 'app/assets/stylesheets/application.css.scss 7:9 root stylesheet\n', + '../../../../app/assets/stylesheets/design-system-waiting-room.scss 31:2 @forward\n' + + '../../../../app/assets/stylesheets/application.css.scss 4:1 root stylesheet\n', + 'node_modules/sass-embedded-darwin-arm64/dart-sass/src/dart', ); expect(stackPaths).to.deep.equal([ - 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/_functions.scss', - 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/mixins/_icon.scss', - 'app/assets/stylesheets/components/_alert.scss', - 'app/assets/stylesheets/components/all.scss', + 'app/assets/stylesheets/design-system-waiting-room.scss', 'app/assets/stylesheets/application.css.scss', ]); }); context('with a stack path containing a space', () => { - it('returns an array of paths from a sass stack message', () => { + it('returns an array of paths from a sass stack message resolved from relative file', () => { const stackPaths = getErrorSassStackPaths( - 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/_functions.scss 35:8 divide()\n' + - 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/mixins/_icon.scss 77:12 add-color-icon()\n' + - 'app/assets/stylesheets/components/_alert example.scss 13:5 @import\n' + - 'app/assets/stylesheets/components/all.scss 3:9 @import\n' + - 'app/assets/stylesheets/application.css.scss 7:9 root stylesheet\n', + '../../../../app/assets/stylesheets/design-system waiting-room.scss 31:2 @forward\n' + + '../../../../app/assets/stylesheets/application.css.scss 4:1 root stylesheet\n', + 'node_modules/sass-embedded-darwin-arm64/dart-sass/src/dart', ); expect(stackPaths).to.deep.equal([ - 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/_functions.scss', - 'node_modules/@18f/identity-design-system/dist/assets/scss/uswds/core/mixins/_icon.scss', - 'app/assets/stylesheets/components/_alert example.scss', - 'app/assets/stylesheets/components/all.scss', + 'app/assets/stylesheets/design-system waiting-room.scss', 'app/assets/stylesheets/application.css.scss', ]); }); diff --git a/app/javascript/packages/phone-input/package.json b/app/javascript/packages/phone-input/package.json index 88e166c96dc..ef0cc19bd69 100644 --- a/app/javascript/packages/phone-input/package.json +++ b/app/javascript/packages/phone-input/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "dependencies": { "intl-tel-input": "^17.0.19", - "libphonenumber-js": "^1.10.59" + "libphonenumber-js": "^1.10.60" }, "sideEffects": [ "./index.ts" diff --git a/app/jobs/get_usps_proofing_results_job.rb b/app/jobs/get_usps_proofing_results_job.rb index d7222a3856a..d0322525ad8 100644 --- a/app/jobs/get_usps_proofing_results_job.rb +++ b/app/jobs/get_usps_proofing_results_job.rb @@ -11,10 +11,10 @@ class GetUspsProofingResultsJob < ApplicationJob SUPPORTED_ID_TYPES = [ "State driver's license", "State non-driver's identification card", - ] + ].freeze SUPPORTED_SECONDARY_ID_TYPES = [ 'Visual Inspection of Name and Address on Primary ID Match', - ] + ].freeze queue_as :long_running @@ -57,8 +57,8 @@ def perform(_now) attr_accessor :enrollment_outcomes DEFAULT_EMAIL_DELAY_IN_HOURS = 1 - REQUEST_DELAY_IN_SECONDS = IdentityConfig.store. - get_usps_proofing_results_job_request_delay_milliseconds / MILLISECONDS_PER_SECOND + REQUEST_DELAY_IN_SECONDS = (IdentityConfig.store. + get_usps_proofing_results_job_request_delay_milliseconds / MILLISECONDS_PER_SECOND).freeze def proofer @proofer ||= UspsInPersonProofing::EnrollmentHelper.usps_proofer diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 2d93d530235..d51013d7531 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -138,6 +138,37 @@ def new_device_sign_in(date:, location:, device_name:, disavowal_token:) end end + # @param [Array] events Array of sign-in Event records (event types "sign_in_before_2fa", + # "sign_in_after_2fa", "sign_in_unsuccessful_2fa") + # @param [String] disavowal_token Token to generate URL for disavowing event + def new_device_sign_in_after_2fa(events:, disavowal_token:) + with_user_locale(user) do + @events = events + @disavowal_token = disavowal_token + + mail( + to: email_address.email, + subject: t('user_mailer.new_device_sign_in_after_2fa.subject', app_name: APP_NAME), + ) + end + end + + # @param [Array] events Array of sign-in Event records (event types "sign_in_before_2fa", + # "sign_in_after_2fa", "sign_in_unsuccessful_2fa") + # @param [String] disavowal_token Token to generate URL for disavowing event + def new_device_sign_in_before_2fa(events:, disavowal_token:) + with_user_locale(user) do + @events = events + @disavowal_token = disavowal_token + @failed_times = events.count { |event| event.event_type == 'sign_in_unsuccessful_2fa' } + + mail( + to: email_address.email, + subject: t('user_mailer.new_device_sign_in_before_2fa.subject', app_name: APP_NAME), + ) + end + end + def personal_key_regenerated with_user_locale(user) do mail(to: email_address.email, subject: t('user_mailer.personal_key_regenerated.subject')) diff --git a/app/models/event.rb b/app/models/event.rb index 13c07d6bdd4..596bf9d74ca 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -27,6 +27,7 @@ class Event < ApplicationRecord email_deleted: 20, phone_added: 21, password_invalidated: 22, + sign_in_unsuccessful_2fa: 23, } validates :event_type, presence: true diff --git a/app/models/gpo_confirmation.rb b/app/models/gpo_confirmation.rb index 04a99d6e903..6d69cbca090 100644 --- a/app/models/gpo_confirmation.rb +++ b/app/models/gpo_confirmation.rb @@ -3,7 +3,7 @@ class GpoConfirmation < ApplicationRecord self.table_name = 'usps_confirmations' - ENTRY_ATTRIBUTES = %i[otp address1 city state zipcode] + ENTRY_ATTRIBUTES = %i[otp address1 city state zipcode].freeze ENTRY_ATTRIBUTES.each do |attr| define_method(:"entry_#{attr}") do entry[attr] diff --git a/app/models/service_provider_identity.rb b/app/models/service_provider_identity.rb index 84dcc6d148a..2c1bb0272b4 100644 --- a/app/models/service_provider_identity.rb +++ b/app/models/service_provider_identity.rb @@ -18,7 +18,7 @@ class ServiceProviderIdentity < ApplicationRecord scope :not_deleted, -> { where(deleted_at: nil) } - CONSENT_EXPIRATION = 1.year + CONSENT_EXPIRATION = 1.year.freeze def deactivate update!(session_uuid: nil) diff --git a/app/presenters/account_show_presenter.rb b/app/presenters/account_show_presenter.rb index 0b944d8f4f4..109bc1286c5 100644 --- a/app/presenters/account_show_presenter.rb +++ b/app/presenters/account_show_presenter.rb @@ -89,7 +89,7 @@ def totp_content :dob, :phone, keyword_init: true, - ) + ).freeze def obfuscated_pii_accessor PiiAccessor.new( diff --git a/app/presenters/idv/in_person/ready_to_verify_presenter.rb b/app/presenters/idv/in_person/ready_to_verify_presenter.rb index c84b0eb2e57..36efbcb2b49 100644 --- a/app/presenters/idv/in_person/ready_to_verify_presenter.rb +++ b/app/presenters/idv/in_person/ready_to_verify_presenter.rb @@ -4,7 +4,7 @@ module Idv module InPerson class ReadyToVerifyPresenter # WILLFIX: With LG-6881, confirm timezone or use deadline from enrollment response. - USPS_SERVER_TIMEZONE = ActiveSupport::TimeZone['America/New_York'] + USPS_SERVER_TIMEZONE = ActiveSupport::TimeZone['America/New_York'].dup.freeze attr_reader :barcode_image_url diff --git a/app/presenters/idv/in_person/verification_results_email_presenter.rb b/app/presenters/idv/in_person/verification_results_email_presenter.rb index 2a11a73351a..3fae3d98a69 100644 --- a/app/presenters/idv/in_person/verification_results_email_presenter.rb +++ b/app/presenters/idv/in_person/verification_results_email_presenter.rb @@ -8,7 +8,7 @@ class VerificationResultsEmailPresenter attr_reader :enrollment, :url_options # update to user's time zone when out of pilot - USPS_SERVER_TIMEZONE = ActiveSupport::TimeZone['America/New_York'] + USPS_SERVER_TIMEZONE = ActiveSupport::TimeZone['America/New_York'].dup.freeze def initialize(enrollment:, url_options:) @enrollment = enrollment diff --git a/app/presenters/webauthn_setup_presenter.rb b/app/presenters/webauthn_setup_presenter.rb index 7fbcc3810d0..eba26a0ffc9 100644 --- a/app/presenters/webauthn_setup_presenter.rb +++ b/app/presenters/webauthn_setup_presenter.rb @@ -4,6 +4,7 @@ class WebauthnSetupPresenter < SetupPresenter include Rails.application.routes.url_helpers include ActionView::Helpers::UrlHelper include ActionView::Helpers::TranslationHelper + include LinkHelper attr_reader :url_options @@ -26,11 +27,18 @@ def initialize( @url_options = url_options end - def image_path - if @platform_authenticator - 'platform-authenticator.svg' - else - 'security-key.svg' + def learn_more_html + if !@platform_authenticator + new_tab_link_to( + t('forms.webauthn_setup.learn_more'), + help_center_redirect_path( + category: 'get-started', + article: 'authentication-options', + article_anchor: 'security-key', + flow: :two_factor_authentication, + step: :security_key_setup, + ), + ) end end @@ -71,7 +79,7 @@ def intro_html ), ) else - t('forms.webauthn_setup.intro_html') + t('forms.webauthn_setup.intro', app_name: APP_NAME) end end @@ -87,7 +95,7 @@ def button_text if @platform_authenticator t('forms.webauthn_platform_setup.continue') else - t('forms.webauthn_setup.continue') + t('forms.webauthn_setup.set_up') end end end diff --git a/app/services/browser_cache.rb b/app/services/browser_cache.rb index 802a7ba40f3..71bdf379430 100644 --- a/app/services/browser_cache.rb +++ b/app/services/browser_cache.rb @@ -2,7 +2,9 @@ class BrowserCache @cache = LruRedux::Cache.new(1_000) + # rubocop:disable Style/MutableConstant DEFAULT_BROWSER = Browser.new(nil) + # rubocop:enable Style/MutableConstant USER_AGENT_SIZE = Browser.user_agent_size_limit - 1 # Detects browser attributes from User-Agent, truncated to 2047 bytes due diff --git a/app/services/create_new_device_alert.rb b/app/services/create_new_device_alert.rb new file mode 100644 index 00000000000..e038aec5f04 --- /dev/null +++ b/app/services/create_new_device_alert.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class CreateNewDeviceAlert < ApplicationJob + queue_as :long_running + + def perform(now) + emails_sent = 0 + User.where( + sql_query_for_users_with_new_device, + tvalue: now - IdentityConfig.store.new_device_alert_delay_in_minutes.minutes, + ).each do |user| + emails_sent += 1 if clear_new_device_and_send_email(user) + end + + emails_sent + end + + private + + def sql_query_for_users_with_new_device + <<~SQL + sign_in_new_device_at IS NOT NULL AND + sign_in_new_device_at < :tvalue + SQL + end + + def clear_new_device_and_send_email(user) + UserAlerts::AlertUserAboutNewDevice.send_alert(user) + + true + end +end diff --git a/app/services/doc_auth/error_generator.rb b/app/services/doc_auth/error_generator.rb index 74f56ec6e05..89dacadb736 100644 --- a/app/services/doc_auth/error_generator.rb +++ b/app/services/doc_auth/error_generator.rb @@ -11,7 +11,7 @@ def handle(response_info) class IdTypeErrorHandler < ErrorHandler SUPPORTED_ID_CLASSNAME = ['Identification Card', 'Drivers License'].freeze ACCEPTED_ISSUER_TYPES = [DocAuth::LexisNexis::IssuerTypes::STATE_OR_PROVINCE.name, - DocAuth::LexisNexis::IssuerTypes::UNKNOWN.name] + DocAuth::LexisNexis::IssuerTypes::UNKNOWN.name].freeze def handle(response_info) get_id_type_errors(response_info[:classification_info]) end @@ -114,7 +114,7 @@ def is_generic_selfie_error?(error) error == Errors::SELFIE_FAILURE end - SELFIE_GENERAL_FAILURE_ERROR = + def selfie_general_failure_error { general: [Errors::SELFIE_FAILURE], front: [Errors::MULTIPLE_FRONT_ID_FAILURES], @@ -122,6 +122,7 @@ def is_generic_selfie_error?(error) selfie: [Errors::SELFIE_FAILURE], hints: false, } + end private @@ -252,7 +253,7 @@ class ErrorGenerator GENERAL = :general ACCEPTED_ISSUER_TYPES = [DocAuth::LexisNexis::IssuerTypes::STATE_OR_PROVINCE.name, - DocAuth::LexisNexis::IssuerTypes::UNKNOWN.name] + DocAuth::LexisNexis::IssuerTypes::UNKNOWN.name].freeze ERROR_KEYS = [ ID, @@ -317,7 +318,7 @@ def generate_doc_auth_errors(response_info) # if selfie itself is ok, but we have selfie related error if selfie_error_handler.is_generic_selfie_error?(selfie_error) - return SelfieErrorHandler::SELFIE_GENERAL_FAILURE_ERROR + return selfie_error_handler.selfie_general_failure_error end # other vendor response detail error diff --git a/app/services/doc_auth/errors.rb b/app/services/doc_auth/errors.rb index bf645210e15..262b0ed3590 100644 --- a/app/services/doc_auth/errors.rb +++ b/app/services/doc_auth/errors.rb @@ -123,7 +123,7 @@ module Errors SELFIE_FAILURE => { long_msg: SELFIE_FAILURE, field_msg: SELFIE_FAILURE, hints: false }, SELFIE_NOT_LIVE => { long_msg: SELFIE_NOT_LIVE, field_msg: SELFIE_FAILURE, hints: false }, SELFIE_POOR_QUALITY => { long_msg: SELFIE_POOR_QUALITY, field_msg: SELFIE_FAILURE, hints: false }, - } + }.transform_values(&:freeze).freeze # rubocop:enable Layout/LineLength end end diff --git a/app/services/doc_auth/lexis_nexis/config.rb b/app/services/doc_auth/lexis_nexis/config.rb index 52a5ee050be..f689212eb9e 100644 --- a/app/services/doc_auth/lexis_nexis/config.rb +++ b/app/services/doc_auth/lexis_nexis/config.rb @@ -39,6 +39,6 @@ def validate! raise 'config missing base_url' if !base_url raise 'config missing locale' if !locale end - end + end.freeze end end diff --git a/app/services/doc_auth/mock/config.rb b/app/services/doc_auth/mock/config.rb index 1976cae432d..6f50f497bba 100644 --- a/app/services/doc_auth/mock/config.rb +++ b/app/services/doc_auth/mock/config.rb @@ -13,6 +13,6 @@ module Mock :sharpness_threshold, :glare_threshold, ], - ) + ).freeze end end diff --git a/app/services/doc_auth/response.rb b/app/services/doc_auth/response.rb index 64e60231114..cee56818514 100644 --- a/app/services/doc_auth/response.rb +++ b/app/services/doc_auth/response.rb @@ -10,7 +10,7 @@ class Response ID_TYPE_SLUGS = { 'Identification Card' => 'state_id_card', 'Drivers License' => 'drivers_license', - } + }.freeze def initialize( success:, diff --git a/app/services/doc_auth/selfie_concern.rb b/app/services/doc_auth/selfie_concern.rb index 9cbc24fbada..0c518e0118c 100644 --- a/app/services/doc_auth/selfie_concern.rb +++ b/app/services/doc_auth/selfie_concern.rb @@ -33,13 +33,13 @@ def selfie_check_performed? private - SELFIE_PERFORMED_STATUSES = %i[success fail] + SELFIE_PERFORMED_STATUSES = %i[success fail].freeze ERROR_TEXTS = { success: 'Successful. Liveness: Live', not_live: 'Liveness: NotLive', poor_quality: 'Liveness: PoorQuality', - } + }.freeze # @param [Object] portrait_match_results trueid portait match info def get_portrait_error(portrait_match_results) diff --git a/app/services/doc_auth_router.rb b/app/services/doc_auth_router.rb index 29e615654ef..38331817ffd 100644 --- a/app/services/doc_auth_router.rb +++ b/app/services/doc_auth_router.rb @@ -199,18 +199,11 @@ def self.client(vendor_discriminator: nil, warn_notifier: nil, analytics: nil) def self.doc_auth_vendor(discriminator: nil, analytics: nil) case AbTests::DOC_AUTH_VENDOR.bucket(discriminator) when :alternate_vendor - vendor = IdentityConfig.store.doc_auth_vendor_randomize_alternate_vendor + IdentityConfig.store.doc_auth_vendor_randomize_alternate_vendor else analytics&.idv_doc_auth_randomizer_defaulted if discriminator.blank? - vendor = IdentityConfig.store.doc_auth_vendor + IdentityConfig.store.doc_auth_vendor end - - # if vendor is not set to mock and selfie enabled use lexisnexis - if FeatureManagement.idv_allow_selfie_check? && - vendor != Idp::Constants::Vendors::MOCK - vendor = Idp::Constants::Vendors::LEXIS_NEXIS - end - vendor end end diff --git a/app/services/document_capture_session_result.rb b/app/services/document_capture_session_result.rb index 4c50a0ce2d4..31ddc9b5e0b 100644 --- a/app/services/document_capture_session_result.rb +++ b/app/services/document_capture_session_result.rb @@ -45,4 +45,4 @@ def selfie_status return self[member_name]&.include?(fingerprint) end end -end +end.freeze diff --git a/app/services/encryption/contextless_kms_client.rb b/app/services/encryption/contextless_kms_client.rb index 23494bfef0d..3500b968fcb 100644 --- a/app/services/encryption/contextless_kms_client.rb +++ b/app/services/encryption/contextless_kms_client.rb @@ -12,7 +12,7 @@ class ContextlessKmsClient instance_profile_credentials_timeout: 1, # defaults to 1 second instance_profile_credentials_retries: 5, # defaults to 0 retries ) - end + end.freeze KEY_TYPE = { KMS: 'KMSx', diff --git a/app/services/encryption/encryptors/pii_encryptor.rb b/app/services/encryption/encryptors/pii_encryptor.rb index 0a74c3e9903..c577f04b9b0 100644 --- a/app/services/encryption/encryptors/pii_encryptor.rb +++ b/app/services/encryption/encryptors/pii_encryptor.rb @@ -33,7 +33,7 @@ def self.extract_encrypted_data(parsed_json) ) decode(encoded_encrypted_data) end - end + end.freeze def initialize(password) @password = password diff --git a/app/services/encryption/kms_client.rb b/app/services/encryption/kms_client.rb index 7d059229373..308606a7d1c 100644 --- a/app/services/encryption/kms_client.rb +++ b/app/services/encryption/kms_client.rb @@ -22,7 +22,7 @@ class KmsClient instance_profile_credentials_retries: 5, # defaults to 0 retries region: IdentityConfig.store.aws_region, # The region in which the client is being instantiated ) - end + end.freeze # rubocop:enable Layout/LineLength attr_reader :kms_key_id diff --git a/app/services/encryption/password_verifier.rb b/app/services/encryption/password_verifier.rb index d8bd2ec082f..95f0a52d6e9 100644 --- a/app/services/encryption/password_verifier.rb +++ b/app/services/encryption/password_verifier.rb @@ -29,7 +29,7 @@ def to_s def uak_password_digest? encryption_key.present? end - end + end.freeze def initialize @aes_cipher = AesCipher.new diff --git a/app/services/encryption/regional_ciphertext_pair.rb b/app/services/encryption/regional_ciphertext_pair.rb index a3d3a6ef370..09db686c95b 100644 --- a/app/services/encryption/regional_ciphertext_pair.rb +++ b/app/services/encryption/regional_ciphertext_pair.rb @@ -10,4 +10,4 @@ def to_ary def multi_or_single_region_ciphertext multi_region_ciphertext.presence || single_region_ciphertext end -end +end.freeze diff --git a/app/services/encryption/uak_password_verifier.rb b/app/services/encryption/uak_password_verifier.rb index fad98691fd0..c5c91cf0e80 100644 --- a/app/services/encryption/uak_password_verifier.rb +++ b/app/services/encryption/uak_password_verifier.rb @@ -28,7 +28,7 @@ def to_s password_cost: password_cost, }.to_json end - end + end.freeze def self.digest(password) salt = SecureRandom.hex(32) diff --git a/app/services/event_disavowal/find_disavowed_event.rb b/app/services/event_disavowal/find_disavowed_event.rb index 8d8a62e951f..8d1e5630847 100644 --- a/app/services/event_disavowal/find_disavowed_event.rb +++ b/app/services/event_disavowal/find_disavowed_event.rb @@ -15,11 +15,8 @@ def call private def event - # Use `#all` here instead of `#first` to avoid setting a 'LIMIT 1' to the - # postgres query which causes it to run slowly. - @event ||= Event.where( - disavowal_token_fingerprint: disavowal_token_fingerprints, - ).all[0] + return @event if defined?(@event) + @event = Event.find_by(disavowal_token_fingerprint: disavowal_token_fingerprints) end def disavowal_token_fingerprints diff --git a/app/services/marketing_site.rb b/app/services/marketing_site.rb index 42b9c7be9bf..b3eb7655839 100644 --- a/app/services/marketing_site.rb +++ b/app/services/marketing_site.rb @@ -7,6 +7,7 @@ class UnknownArticleException < StandardError; end HELP_CENTER_ARTICLES = %w[ get-started/authentication-options + manage-your-account/add-or-change-your-authentication-method manage-your-account/personal-key trouble-signing-in/face-or-touch-unlock verify-your-identity/accepted-identification-documents diff --git a/app/services/openid_connect_attribute_scoper.rb b/app/services/openid_connect_attribute_scoper.rb index 81a869b230d..0886ab7230c 100644 --- a/app/services/openid_connect_attribute_scoper.rb +++ b/app/services/openid_connect_attribute_scoper.rb @@ -6,7 +6,7 @@ class OpenidConnectAttributeScoper x509:subject x509:issuer x509:presented - ] + ].freeze IAL2_SCOPES = %w[ address @@ -15,21 +15,21 @@ class OpenidConnectAttributeScoper profile:name profile:birthdate social_security_number - ] + ].freeze - VALID_SCOPES = %w[ + VALID_SCOPES = (%w[ email all_emails openid profile:verified_at - ] + X509_SCOPES + IAL2_SCOPES + ] + X509_SCOPES + IAL2_SCOPES).freeze - VALID_IAL1_SCOPES = %w[ + VALID_IAL1_SCOPES = (%w[ email all_emails openid profile:verified_at - ] + X509_SCOPES + ] + X509_SCOPES).freeze ATTRIBUTE_SCOPES_MAP = { email: %w[email], @@ -58,7 +58,7 @@ class OpenidConnectAttributeScoper end end.with_indifferent_access.freeze - CLAIMS = ATTRIBUTE_SCOPES_MAP.keys + CLAIMS = ATTRIBUTE_SCOPES_MAP.keys.freeze attr_reader :scopes diff --git a/app/services/personal_key_formatter.rb b/app/services/personal_key_formatter.rb index 8b80d962067..64d92a760b6 100644 --- a/app/services/personal_key_formatter.rb +++ b/app/services/personal_key_formatter.rb @@ -2,11 +2,12 @@ class PersonalKeyFormatter CHAR_COUNT = RandomPhrase::WORD_LENGTH - WORD_COUNT = IdentityConfig.store.recovery_code_length + WORD_COUNT = IdentityConfig.store.recovery_code_length.freeze VALID_CHAR = '[a-zA-Z0-9]' VALID_WORD = "#{VALID_CHAR}{#{CHAR_COUNT}}".freeze REGEXP_STRING = "(?:#{VALID_WORD}([\\s\\-])?){#{WORD_COUNT - 1}}#{VALID_WORD}".freeze REGEXP = /\A#{REGEXP_STRING}\Z/o + CODE_LENGTH = (CHAR_COUNT * WORD_COUNT + (WORD_COUNT - 1)).freeze def self.regexp REGEXP @@ -17,6 +18,6 @@ def self.regexp_string end def self.code_length - CHAR_COUNT * WORD_COUNT + (WORD_COUNT - 1) + CODE_LENGTH end end diff --git a/app/services/phone_number_capabilities.rb b/app/services/phone_number_capabilities.rb index f29298e0a36..0318433cc35 100644 --- a/app/services/phone_number_capabilities.rb +++ b/app/services/phone_number_capabilities.rb @@ -9,7 +9,7 @@ def self.load_config INTERNATIONAL_CODES = load_config.freeze ADDRESS_IDENTITY_PROOFING_SUPPORTED_COUNTRY_CODES = - IdentityConfig.store.address_identity_proofing_supported_country_codes + IdentityConfig.store.address_identity_proofing_supported_country_codes.freeze attr_reader :phone, :phone_confirmed @@ -34,7 +34,7 @@ def self.translated_international_codes translated_intl_codes_data[I18n.locale] if translated_intl_codes_data end - TRANSLATED_INTL_CODES_DATA = generate_translated_international_codes_data + TRANSLATED_INTL_CODES_DATA = generate_translated_international_codes_data.freeze def initialize(phone, phone_confirmed:) @phone = phone diff --git a/app/services/pii/attributes.rb b/app/services/pii/attributes.rb index c9edf807fce..47c7dc3a9aa 100644 --- a/app/services/pii/attributes.rb +++ b/app/services/pii/attributes.rb @@ -42,5 +42,5 @@ def eql?(other) def ==(other) eql?(other) end - end + end.freeze end diff --git a/app/services/proofing/aamva/proofer.rb b/app/services/proofing/aamva/proofer.rb index 0daa79f0e08..c73841282d8 100644 --- a/app/services/proofing/aamva/proofer.rb +++ b/app/services/proofing/aamva/proofer.rb @@ -19,7 +19,7 @@ class Proofer :verification_request_timeout, :verification_url, ], - ) + ).freeze attr_reader :config diff --git a/app/services/proofing/lexis_nexis/config.rb b/app/services/proofing/lexis_nexis/config.rb index a09f4e3fd25..e650a58053b 100644 --- a/app/services/proofing/lexis_nexis/config.rb +++ b/app/services/proofing/lexis_nexis/config.rb @@ -23,6 +23,6 @@ module LexisNexis :request_mode, :request_timeout, ], - ) + ).freeze end end diff --git a/app/services/proofing/lexis_nexis/ddp/proofer.rb b/app/services/proofing/lexis_nexis/ddp/proofer.rb index 246c6957955..6b7d296c312 100644 --- a/app/services/proofing/lexis_nexis/ddp/proofer.rb +++ b/app/services/proofing/lexis_nexis/ddp/proofer.rb @@ -4,7 +4,7 @@ module Proofing module LexisNexis module Ddp class Proofer - VALID_REVIEW_STATUSES = %w[pass review reject] + VALID_REVIEW_STATUSES = %w[pass review reject].freeze attr_reader :config diff --git a/app/services/proofing/lexis_nexis/ddp/response_redacter.rb b/app/services/proofing/lexis_nexis/ddp/response_redacter.rb index 377f13e0b5b..fb9cda806d9 100644 --- a/app/services/proofing/lexis_nexis/ddp/response_redacter.rb +++ b/app/services/proofing/lexis_nexis/ddp/response_redacter.rb @@ -191,7 +191,7 @@ class ResponseRedacter true_ip_score true_ip_worst_score unknown_session - ] + ].freeze # @param [Hash, nil] parsed JSON response body def self.redact(hash) diff --git a/app/services/proofing/mock/ddp_mock_client.rb b/app/services/proofing/mock/ddp_mock_client.rb index 18282101261..772443a48b6 100644 --- a/app/services/proofing/mock/ddp_mock_client.rb +++ b/app/services/proofing/mock/ddp_mock_client.rb @@ -37,7 +37,7 @@ def stage 'proofing', 'lexis_nexis', 'ddp', - ) + ).freeze TRANSACTION_ID = 'ddp-mock-transaction-id-123' def initialize(response_fixture_file: 'successful_response.json') diff --git a/app/services/rate_limiter.rb b/app/services/rate_limiter.rb index 41700da49e1..f25751f9246 100644 --- a/app/services/rate_limiter.rb +++ b/app/services/rate_limiter.rb @@ -218,6 +218,11 @@ def self.load_rate_limit_config max_attempts: IdentityConfig.store.otp_delivery_blocklist_maxretry + 1, attempt_window: IdentityConfig.store.otp_delivery_blocklist_findtime, }, + short_term_phone_otp: { + max_attempts: IdentityConfig.store.short_term_phone_otp_max_attempts, + attempt_window: IdentityConfig.store. + short_term_phone_otp_max_attempt_window_in_seconds.seconds.in_minutes.to_f, + }, }.with_indifferent_access end diff --git a/app/services/recaptcha_validator.rb b/app/services/recaptcha_validator.rb index 95769a31473..b9af5e24232 100644 --- a/app/services/recaptcha_validator.rb +++ b/app/services/recaptcha_validator.rb @@ -2,8 +2,8 @@ class RecaptchaValidator VERIFICATION_ENDPOINT = 'https://www.google.com/recaptcha/api/siteverify' - RESULT_ERRORS = ['missing-input-secret', 'invalid-input-secret'] - VALID_RECAPTCHA_VERSIONS = [2, 3] + RESULT_ERRORS = ['missing-input-secret', 'invalid-input-secret'].freeze + VALID_RECAPTCHA_VERSIONS = [2, 3].freeze attr_reader :recaptcha_version, :recaptcha_action, diff --git a/app/services/request_password_reset.rb b/app/services/request_password_reset.rb index d69741d6548..c5b9505f049 100644 --- a/app/services/request_password_reset.rb +++ b/app/services/request_password_reset.rb @@ -85,4 +85,4 @@ def email_address_record EmailAddress.find_with_confirmed_or_unconfirmed_email(email) end end -end +end.freeze diff --git a/app/services/service_provider_updater.rb b/app/services/service_provider_updater.rb index f8bbab9effa..c3a9a8d6bc2 100644 --- a/app/services/service_provider_updater.rb +++ b/app/services/service_provider_updater.rb @@ -10,7 +10,7 @@ class ServiceProviderUpdater SP_IGNORED_ATTRIBUTES = %i[ cert - ] + ].freeze def run(service_provider = nil) if service_provider.present? diff --git a/app/services/user_alerts/alert_user_about_new_device.rb b/app/services/user_alerts/alert_user_about_new_device.rb index f2fec1b5462..f1ae603625e 100644 --- a/app/services/user_alerts/alert_user_about_new_device.rb +++ b/app/services/user_alerts/alert_user_about_new_device.rb @@ -3,19 +3,37 @@ module UserAlerts class AlertUserAboutNewDevice def self.call(user, device, disavowal_token) - device_decorator = DeviceDecorator.new(device) - login_location = device_decorator.last_sign_in_location_and_ip - device_name = device_decorator.nice_name + if IdentityConfig.store.feature_new_device_alert_aggregation_enabled + user.sign_in_new_device_at ||= Time.zone.now + user.save + else + device_decorator = DeviceDecorator.new(device) + login_location = device_decorator.last_sign_in_location_and_ip + device_name = device_decorator.nice_name - user.confirmed_email_addresses.each do |email_address| - UserMailer.with(user: user, email_address: email_address).new_device_sign_in( - date: device.last_used_at.in_time_zone('Eastern Time (US & Canada)'). - strftime('%B %-d, %Y %H:%M Eastern Time'), - location: login_location, - device_name: device_name, - disavowal_token: disavowal_token, - ).deliver_now_or_later + user.confirmed_email_addresses.each do |email_address| + UserMailer.with(user: user, email_address: email_address).new_device_sign_in( + date: device.last_used_at.in_time_zone('Eastern Time (US & Canada)'). + strftime('%B %-d, %Y %H:%M Eastern Time'), + location: login_location, + device_name: device_name, + disavowal_token: disavowal_token, + ).deliver_now_or_later + end end end + + def self.send_alert(user) + user.update(sign_in_new_device_at: nil) + # Stub out for possible email in follow-up work + # disavowal_token = SecureRandom.urlsafe_base64(32) + + # user.confirmed_email_addresses.each do |email_address| + # UserMailer.with(user: user, email_address: email_address).new_device_sign_in( + # events: events, + # disavowal_token: disavowal_token, + # ).deliver_now_or_later + # end + end end end diff --git a/app/services/usps_in_person_proofing/proofer.rb b/app/services/usps_in_person_proofing/proofer.rb index b4f52e5e8ee..0ad3a48f13c 100644 --- a/app/services/usps_in_person_proofing/proofer.rb +++ b/app/services/usps_in_person_proofing/proofer.rb @@ -4,7 +4,7 @@ module UspsInPersonProofing class Proofer AUTH_TOKEN_CACHE_KEY = :usps_ippaas_api_auth_token # Automatically refresh our auth token if it is within this many minutes of expiring - AUTH_TOKEN_PREEMPTIVE_EXPIRY_MINUTES = 1.minute + AUTH_TOKEN_PREEMPTIVE_EXPIRY_MINUTES = 1.minute.freeze # Makes HTTP request to get nearby in-person proofing facilities # Requires address, city, state and zip code. diff --git a/app/services/vot/component_value.rb b/app/services/vot/component_value.rb index 2eccdf1efec..01c3785350a 100644 --- a/app/services/vot/component_value.rb +++ b/app/services/vot/component_value.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Vot - ComponentValue = Data.define(:name, :description, :implied_component_values, :requirements) + ComponentValue = Data.define(:name, :description, :implied_component_values, :requirements).freeze end diff --git a/app/services/vot/legacy_component_values.rb b/app/services/vot/legacy_component_values.rb index 9d0a905cd3b..5f3d2db3459 100644 --- a/app/services/vot/legacy_component_values.rb +++ b/app/services/vot/legacy_component_values.rb @@ -8,31 +8,31 @@ module LegacyComponentValues description: 'Legacy LOA1', implied_component_values: [], requirements: [], - ) + ).freeze LOA3 = ComponentValue.new( name: Saml::Idp::Constants::LOA3_AUTHN_CONTEXT_CLASSREF, description: 'Legacy LOA3', implied_component_values: [], requirements: [:aal2, :identity_proofing], - ) + ).freeze IAL1 = ComponentValue.new( name: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, description: 'Legacy IAL1', implied_component_values: [], requirements: [], - ) + ).freeze IAL2 = ComponentValue.new( name: Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, description: 'Legacy IAL2', implied_component_values: [], requirements: [:aal2, :identity_proofing], - ) + ).freeze IALMAX = ComponentValue.new( name: Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF, description: 'Legacy IALMAX', implied_component_values: [], requirements: [:aal2, :ialmax], - ) + ).freeze ## Authentication ACR values DEFAULT = ComponentValue.new( @@ -40,48 +40,48 @@ module LegacyComponentValues description: 'Legacy default authentication', implied_component_values: [], requirements: [], - ) + ).freeze AAL1 = ComponentValue.new( name: Saml::Idp::Constants::AAL1_AUTHN_CONTEXT_CLASSREF, description: 'Legacy AAL1', implied_component_values: [], requirements: [], - ) + ).freeze AAL2 = ComponentValue.new( name: Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF, description: 'Legacy AAL2', implied_component_values: [], requirements: [:aal2], - ) + ).freeze AAL2_PHISHING_RESISTANT = ComponentValue.new( name: Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, description: 'Legacy AAL2 with phishing resistance', implied_component_values: [], requirements: [:aal2, :phishing_resistant], - ) + ).freeze AAL2_HSPD12 = ComponentValue.new( name: Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF, description: 'Legacy AAL2 with HSPD12', implied_component_values: [], requirements: [:aal2, :hspd12], - ) + ).freeze AAL3 = ComponentValue.new( name: Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF, description: 'Legacy AAL3', implied_component_values: [], requirements: [:aal2, :phishing_resistant], - ) + ).freeze AAL3_HSPD12 = ComponentValue.new( name: Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF, description: 'Legacy AAL3 with HSPD12', implied_component_values: [], requirements: [:aal2, :hspd12], - ) + ).freeze NAME_HASH = constants.map do |constant| component_value = const_get(constant) [component_value.name, component_value] - end.to_h + end.to_h.freeze def self.by_name NAME_HASH diff --git a/app/services/vot/parser.rb b/app/services/vot/parser.rb index 2931d3cd9d5..80e7cbee65d 100644 --- a/app/services/vot/parser.rb +++ b/app/services/vot/parser.rb @@ -32,7 +32,7 @@ def identity_proofing_or_ialmax? def expanded_component_values component_values.map(&:name).join('.') end - end + end.freeze attr_reader :vector_of_trust, :acr_values diff --git a/app/services/vot/supported_component_values.rb b/app/services/vot/supported_component_values.rb index 9f41950bb63..d946e355945 100644 --- a/app/services/vot/supported_component_values.rb +++ b/app/services/vot/supported_component_values.rb @@ -7,42 +7,42 @@ module SupportedComponentValues description: 'Multi-factor authentication', implied_component_values: [], requirements: [], - ) + ).freeze C2 = ComponentValue.new( name: 'C2', description: 'AAL2 conformant features are engaged', implied_component_values: [C1], requirements: [:aal2], - ) + ).freeze Ca = ComponentValue.new( name: 'Ca', description: 'A phishing resistant authenticator is required', implied_component_values: [C1], requirements: [:phishing_resistant], - ) + ).freeze Cb = ComponentValue.new( name: 'Cb', description: 'A PIV/CAC card is required', implied_component_values: [C1], requirements: [:hspd12], - ) + ).freeze P1 = ComponentValue.new( name: 'P1', description: 'Identity proofing is performed', implied_component_values: [C2], requirements: [:identity_proofing], - ) + ).freeze Pb = ComponentValue.new( name: 'Pb', description: 'A biometric comparison is required as part of identity proofing', implied_component_values: [P1], requirements: [:biometric_comparison], - ) + ).freeze NAME_HASH = constants.map do |constant| component_value = const_get(constant) [component_value.name, component_value] - end.to_h + end.to_h.freeze def self.by_name NAME_HASH diff --git a/app/validators/form_password_validator.rb b/app/validators/form_password_validator.rb index 0c7dcc906c6..7268e039ea9 100644 --- a/app/validators/form_password_validator.rb +++ b/app/validators/form_password_validator.rb @@ -20,7 +20,7 @@ module FormPasswordValidator private - ZXCVBN_TESTER = ::Zxcvbn::Tester.new + ZXCVBN_TESTER = ::Zxcvbn::Tester.new.freeze def strong_password return unless errors.messages.blank? && password_score.score < min_password_score diff --git a/app/views/idv/how_to_verify/show.html.erb b/app/views/idv/how_to_verify/show.html.erb index 2955d67dbce..7a2b5c96e9a 100644 --- a/app/views/idv/how_to_verify/show.html.erb +++ b/app/views/idv/how_to_verify/show.html.erb @@ -16,8 +16,11 @@
<%= simple_form_for( @idv_how_to_verify_form, - html: { autocomplete: 'off', - 'aria-label': t('forms.buttons.continue_remote') }, + html: { + autocomplete: 'off', + id: nil, + aria: { label: t('forms.buttons.continue_remote') }, + }, method: :put, url: idv_how_to_verify_url, ) do |f| @@ -53,8 +56,11 @@
<%= simple_form_for( @idv_how_to_verify_form, - html: { autocomplete: 'off', - 'aria-label': t('forms.buttons.continue_ipp') }, + html: { + autocomplete: 'off', + id: nil, + aria: { label: t('forms.buttons.continue_ipp') }, + }, method: :put, url: idv_how_to_verify_url, ) do |f| diff --git a/app/views/user_mailer/new_device_sign_in_after_2fa.html.erb b/app/views/user_mailer/new_device_sign_in_after_2fa.html.erb new file mode 100644 index 00000000000..a1d9f81298a --- /dev/null +++ b/app/views/user_mailer/new_device_sign_in_after_2fa.html.erb @@ -0,0 +1,26 @@ +

+ <%= t('user_mailer.new_device_sign_in_after_2fa.info_p1', app_name: APP_NAME) %> +

+ +

+ <%= t('user_mailer.new_device_sign_in_after_2fa.info_p2') %> +

+ +

+ <%= t( + 'user_mailer.new_device_sign_in_after_2fa.info_p3_html', + reset_password_link_html: link_to( + t('user_mailer.new_device_sign_in_after_2fa.reset_password'), + event_disavowal_url(disavowal_token: @disavowal_token), + ), + authentication_methods_link_html: link_to( + t('user_mailer.new_device_sign_in_after_2fa.authentication_methods'), + MarketingSite.help_center_article_url( + category: 'manage-your-account', + article: 'add-or-change-your-authentication-method', + ), + ), + ) %> +

+ +<%= render 'user_mailer/shared/new_device_sign_in_attempts' %> diff --git a/app/views/user_mailer/new_device_sign_in_before_2fa.html.erb b/app/views/user_mailer/new_device_sign_in_before_2fa.html.erb new file mode 100644 index 00000000000..e7f59bcd4f4 --- /dev/null +++ b/app/views/user_mailer/new_device_sign_in_before_2fa.html.erb @@ -0,0 +1,19 @@ +

+ <%= t('user_mailer.new_device_sign_in_before_2fa.info_p1_html', count: @failed_times, app_name: APP_NAME) %> +

+ +

+ <%= t('user_mailer.new_device_sign_in_before_2fa.info_p2') %> +

+ +

+ <%= t( + 'user_mailer.new_device_sign_in_before_2fa.info_p3_html', + reset_password_link_html: link_to( + t('user_mailer.new_device_sign_in_before_2fa.reset_password'), + event_disavowal_url(disavowal_token: @disavowal_token), + ), + ) %> +

+ +<%= render 'user_mailer/shared/new_device_sign_in_attempts' %> diff --git a/app/views/user_mailer/shared/_new_device_sign_in_attempts.html.erb b/app/views/user_mailer/shared/_new_device_sign_in_attempts.html.erb new file mode 100644 index 00000000000..09a5fb83791 --- /dev/null +++ b/app/views/user_mailer/shared/_new_device_sign_in_attempts.html.erb @@ -0,0 +1,26 @@ +<% @events.group_by { |event| IpGeocoder.new(event.device.last_ip).location }.each do |location, events| %> +
+ + + + + + + + <% events.each do |event| %> + + + + <% end %> + +
+ <%= t('user_mailer.new_device_sign_in_attempts.new_sign_in_from', location:) %> +
+ <%# i18n-tasks-use t('user_mailer.new_device_sign_in_attempts.events.sign_in_after_2fa') %> + <%# i18n-tasks-use t('user_mailer.new_device_sign_in_attempts.events.sign_in_before_2fa') %> + <%# i18n-tasks-use t('user_mailer.new_device_sign_in_attempts.events.sign_in_unsuccessful_2fa') %> + <%= t(event.event_type, scope: [:user_mailer, :new_device_sign_in_attempts, :events]) %>
+ <%= EasternTimePresenter.new(event.created_at) %> +
+
+<% end %> diff --git a/app/views/users/webauthn_setup/new.html.erb b/app/views/users/webauthn_setup/new.html.erb index f0eee7961fc..4b2e391980b 100644 --- a/app/views/users/webauthn_setup/new.html.erb +++ b/app/views/users/webauthn_setup/new.html.erb @@ -1,6 +1,8 @@ <% self.title = @presenter.page_title %> -<%= image_tag asset_url(@presenter.image_path), alt: '', width: '90', class: 'margin-left-1 margin-bottom-2', role: 'img' %> +<% if @platform_authenticator %> + <%= image_tag asset_url('platform-authenticator.svg'), alt: '', width: 84, height: 95, class: 'margin-left-1 margin-bottom-2', role: 'img' %> +<% end %> <%= render PageHeadingComponent.new.with_content(@presenter.heading) %> @@ -10,7 +12,10 @@ <% end %> <% end %> -<%= @presenter.intro_html %> + + <%= @presenter.intro_html %> + +<%= @presenter.learn_more_html unless @platform_authenticator %> <%= simple_form_for( '', @@ -33,21 +38,35 @@ <%= hidden_field_tag :platform_authenticator, @platform_authenticator, id: 'platform_authenticator' %> <% if !@platform_authenticator %> - - <%= render ValidatedFieldComponent.new( - form: f, - name: :name, - required: true, - label: @presenter.nickname_label, - hint: @presenter.device_nickname_hint, - input_html: { - id: 'nickname', - class: 'font-family-mono', - size: 16, - maxlength: 20, - }, - ) %> - <% end %> +
+ <%= render ProcessListComponent.new(connected: true) do |c| %> + <%= c.with_item(heading: t('forms.webauthn_setup.step_1'), heading_id: 'step-1-label') do %> +

<%= t('forms.webauthn_setup.step_1a') %>

+ <%= render ValidatedFieldComponent.new( + form: f, + name: :name, + required: true, + label: false, + hint: @presenter.device_nickname_hint, + wrapper_html: { class: 'margin-bottom-0' }, + input_html: { + aria: { labelledby: 'step-1-label' }, + id: 'nickname', + class: 'font-family-mono', + size: 16, + maxlength: 20, + }, + ) %> + <% end %> + <%= c.with_item(heading: t('forms.webauthn_setup.step_2')) do %> + <%= render SecurityKeyImageComponent.new(mobile: mobile?) %> + <% end %> + <%= c.with_item(heading: t('forms.webauthn_setup.step_3')) do %> +

<%= t('forms.webauthn_setup.step_3a') %>

+ <% end %> + <% end %> +
+ <% end %>