diff --git a/.circleci/config.yml b/.circleci/config.yml index 87499e403ed..404967a9148 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -184,32 +184,6 @@ jobs: bundle exec rubocop bundle exec slim-lint app/views make check_asset_strings - build-latest-container: - working_directory: ~/identity-idp - docker: - - image: circleci/ruby:2.6 - steps: - - checkout - - setup_remote_docker - - run: | - rev=$(git rev-parse --short HEAD) - docker build -t logindotgov/idp:latest -t logindotgov/idp:"${rev}" -f production.Dockerfile . - echo $DOCKER_PASS | docker login -u $DOCKER_USER --password-stdin - docker push logindotgov/idp:"${rev}" - docker push logindotgov/idp:latest - build-latest-dev-container: - working_directory: ~/identity-idp - docker: - - image: circleci/ruby:2.6 - steps: - - checkout - - setup_remote_docker - - run: | - rev=$(git rev-parse --short HEAD) - docker build -t logindotgov/dev:latest -t logindotgov/dev:"${rev}" -f development.Dockerfile . - echo $DOCKER_PASS | docker login -u $DOCKER_USER --password-stdin - docker push logindotgov/dev:"${rev}" - docker push logindotgov/dev:latest build-release-container: working_directory: ~/identity-idp docker: @@ -285,12 +259,6 @@ workflows: jobs: - build - lints - - build-latest-dev-container: - requires: - - build - - build-latest-container: - requires: - - build - build-release-container: requires: - build diff --git a/.eslintrc b/.eslintrc index 8f663a1707e..78cff3067ca 100644 --- a/.eslintrc +++ b/.eslintrc @@ -42,7 +42,6 @@ "app/components/index", "app/utils/index", "app/pw-toggle", - "app/form-validation", "app/form-field-format", "app/radio-btn", "app/print-personal-key", diff --git a/.gitignore b/.gitignore index ebae29e5a5b..14210a425f8 100644 --- a/.gitignore +++ b/.gitignore @@ -39,16 +39,18 @@ Vagrantfile /config/aws.yml /config/service_providers.yml /config/agencies.yml -/geo_data/* -/keys /coverage /db/*.sqlite3 /doc/search_stats.csv /docs/security/exports /docs/security/opencontrols /fixtures +/geo_data/* +/identity-idp-config +/keys /kitchen/cookbooks /log/* +/postgres-data /public/system /public/user_flows /pwned_passwords/* @@ -58,7 +60,6 @@ Vagrantfile /tmp/* /.tmp/ /vendor/bundle -/postgres-data package-lock.json diff --git a/.rubocop.yml b/.rubocop.yml index 16e609ea076..47654b1455d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -31,6 +31,9 @@ Metrics/AbcSize: Exclude: - spec/**/* +Metrics/CyclomaticComplexity: + Enabled: false + Metrics/BlockLength: CountComments: false # count full line comments? Enabled: true @@ -74,8 +77,8 @@ Metrics/MethodLength: Metrics/ModuleLength: CountComments: false - Max: 100 - Description: Avoid modules longer than 100 lines of code. + Max: 200 + Description: Avoid modules longer than 200 lines of code. Enabled: true Exclude: - spec/**/* @@ -351,3 +354,129 @@ Style/SlicingWithRange: Rails/ApplicationMailer: Enabled: false + +Lint/BinaryOperatorWithIdenticalOperands: + Enabled: true + +Lint/DuplicateElsifCondition: + Enabled: true + +Lint/DuplicateRescueException: + Enabled: true + +Lint/FloatComparison: + Enabled: true + +Lint/MissingSuper: + Enabled: false + +Lint/OutOfRangeRegexpRef: + Enabled: true + +Lint/RedundantRequireStatement: + Enabled: true + +Lint/RedundantSplatExpansion: + Enabled: true + +Lint/SafeNavigationWithEmpty: + Enabled: true + +Lint/SelfAssignment: + Enabled: true + +Lint/TopLevelReturnWithArgument: + Enabled: true + +Style/GlobalStdStream: + Enabled: false + +Style/RedundantAssignment: + Enabled: true + +Style/RedundantFetchBlock: + Enabled: true + +Style/RedundantFileExtensionInRequire: + Enabled: true + +Lint/EmptyConditionalBody: + Enabled: false + +Lint/UnreachableLoop: + Enabled: false + +Style/AccessorGrouping: + Enabled: false + +Style/ArrayCoercion: + Enabled: false + +Style/BisectedAttrAccessor: + Enabled: false + +Style/CaseLikeIf: + Enabled: false + +Style/ExplicitBlockArgument: + Enabled: false + +Style/HashAsLastArrayItem: + Enabled: false + +Style/HashLikeCase: + Enabled: false + +Style/OptionalBooleanParameter: + Enabled: false + +Style/SingleArgumentDig: + Enabled: false + +Style/StringConcatenation: + Enabled: false + +Style/MultilineWhenThen: + Enabled: true + +Style/KeywordParametersOrder: + Enabled: true + +Lint/DuplicateRequire: + Enabled: true + +Lint/TrailingCommaInAttributeDeclaration: + Enabled: true + +Lint/EmptyFile: + Enabled: false + +Lint/UselessMethodDefinition: + Enabled: false + +Style/CombinableLoops: + Enabled: false + +Style/RedundantSelfAssignment: + Enabled: false + +Style/SoleNestedConditional: + Enabled: false + +Lint/UselessTimes: + Enabled: true + +Layout/BeginEndAlignment: + Enabled: true + +Lint/ConstantDefinitionInBlock: + Enabled: false + +Lint/IdentityComparison: + Enabled: true + +Bundler/DuplicatedGem: + Enabled: true + +Naming/BinaryOperatorParameterName: + Enabled: true diff --git a/Gemfile b/Gemfile index ee057cb0dfc..e6bbccafdc3 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}.git" } ruby '~> 2.6.5' -gem 'rails', '~> 5.2.4', '>= 5.2.4.3' +gem 'rails', '~> 5.2.4', '>= 5.2.4.4' gem 'ahoy_matey', '~> 2.2', '>= 2.2.1' gem 'american_date' @@ -11,7 +11,7 @@ gem 'aws-sdk-kms', '~> 1.4' gem 'aws-sdk-ses', '~> 1.6' gem 'base32-crockford' gem 'device_detector' -gem 'devise', '~> 4.7.1' +gem 'devise', '~> 4.7.2' gem 'dotiw', '>= 4.0.1' gem 'exception_notification', '>= 4.4.0' gem 'faraday' @@ -93,7 +93,7 @@ group :development, :test do gem 'psych' gem 'puma' gem 'rspec-rails', '~> 3.9', '>= 3.9.1' - gem 'rubocop', '~> 0.85.0', require: false + gem 'rubocop', '~> 0.91.0', require: false gem 'rubocop-rails', '>= 2.5.2', require: false gem 'slim_lint' end @@ -107,8 +107,8 @@ group :test do gem 'factory_bot_rails', '>= 5.2.0' gem 'faker' gem 'gmail' - gem 'rack-test', '>= 1.1.0' gem 'rack_session_access', '>= 0.2.0' + gem 'rack-test', '>= 1.1.0' gem 'rails-controller-testing', '>= 1.0.4' gem 'shoulda-matchers', '~> 4.0.1', require: false gem 'timecop' diff --git a/Gemfile.lock b/Gemfile.lock index 265737b1109..99e602b9c40 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -68,43 +68,43 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (5.2.4.3) - actionpack (= 5.2.4.3) + actioncable (5.2.4.4) + actionpack (= 5.2.4.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailer (5.2.4.3) - actionpack (= 5.2.4.3) - actionview (= 5.2.4.3) - activejob (= 5.2.4.3) + actionmailer (5.2.4.4) + actionpack (= 5.2.4.4) + actionview (= 5.2.4.4) + activejob (= 5.2.4.4) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.2.4.3) - actionview (= 5.2.4.3) - activesupport (= 5.2.4.3) + actionpack (5.2.4.4) + actionview (= 5.2.4.4) + activesupport (= 5.2.4.4) rack (~> 2.0, >= 2.0.8) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.2.4.3) - activesupport (= 5.2.4.3) + actionview (5.2.4.4) + activesupport (= 5.2.4.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) - activejob (5.2.4.3) - activesupport (= 5.2.4.3) + activejob (5.2.4.4) + activesupport (= 5.2.4.4) globalid (>= 0.3.6) - activemodel (5.2.4.3) - activesupport (= 5.2.4.3) - activerecord (5.2.4.3) - activemodel (= 5.2.4.3) - activesupport (= 5.2.4.3) + activemodel (5.2.4.4) + activesupport (= 5.2.4.4) + activerecord (5.2.4.4) + activemodel (= 5.2.4.4) + activesupport (= 5.2.4.4) arel (>= 9.0) - activestorage (5.2.4.3) - actionpack (= 5.2.4.3) - activerecord (= 5.2.4.3) + activestorage (5.2.4.4) + actionpack (= 5.2.4.4) + activerecord (= 5.2.4.4) marcel (~> 0.3.1) - activesupport (5.2.4.3) + activesupport (5.2.4.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) @@ -159,7 +159,7 @@ GEM ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) base32-crockford (0.1.0) - bcrypt (3.1.13) + bcrypt (3.1.16) benchmark-ips (2.8.2) better_errors (2.7.1) coderay (>= 1.0.0) @@ -229,18 +229,18 @@ GEM unicode_plot (>= 0.0.4, < 1.0.0) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) - device_detector (1.0.3) + device_detector (1.0.4) devise (4.7.2) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - diff-lcs (1.3) + diff-lcs (1.4.4) docile (1.1.5) dotenv (2.7.6) - dotiw (4.0.1) - actionpack (>= 4) + dotiw (5.1.0) + activesupport i18n dumb_delegator (0.8.1) email_spec (2.2.0) @@ -252,15 +252,15 @@ GEM equalizer (0.0.11) errbase (0.2.0) erubi (1.9.0) - exception_notification (4.4.0) + exception_notification (4.4.3) actionmailer (>= 4.0, < 7) activesupport (>= 4.0, < 7) execjs (2.7.0) - factory_bot (5.2.0) - activesupport (>= 4.2.0) - factory_bot_rails (5.2.0) - factory_bot (~> 5.2.0) - railties (>= 4.2.0) + factory_bot (6.1.0) + activesupport (>= 5.0.0) + factory_bot_rails (6.1.0) + factory_bot (~> 6.1.0) + railties (>= 5.0.0) faker (2.7.0) i18n (>= 1.6, < 1.8) faraday (1.0.1) @@ -345,7 +345,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.6.0) + loofah (2.7.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) lumberjack (1.0.13) @@ -365,7 +365,7 @@ GEM mini_histogram (0.1.3) mini_mime (1.0.2) mini_portile2 (2.4.0) - minitest (5.14.1) + minitest (5.14.2) msgpack (1.3.3) multi_xml (0.6.0) multipart-post (2.1.1) @@ -376,7 +376,7 @@ GEM net-ssh (>= 2.6.5) net-ssh (5.2.0) newrelic_rpm (6.12.0.367) - nio4r (2.5.2) + nio4r (2.5.3) nokogiri (1.10.10) mini_portile2 (~> 2.4.0) notiffany (0.1.3) @@ -396,7 +396,7 @@ GEM pg (1.1.4) phonelib (0.6.39) pkcs11 (0.3.2) - premailer (1.11.1) + premailer (1.13.1) addressable css_parser (>= 1.6.0) htmlentities (>= 4.0.0) @@ -415,7 +415,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) psych (3.1.0) - public_suffix (4.0.5) + public_suffix (4.0.6) puma (4.3.5) nio4r (~> 2.0) rack (2.2.3) @@ -436,23 +436,23 @@ GEM rack_session_access (0.2.0) builder (>= 2.0.0) rack (>= 1.0.0) - rails (5.2.4.3) - actioncable (= 5.2.4.3) - actionmailer (= 5.2.4.3) - actionpack (= 5.2.4.3) - actionview (= 5.2.4.3) - activejob (= 5.2.4.3) - activemodel (= 5.2.4.3) - activerecord (= 5.2.4.3) - activestorage (= 5.2.4.3) - activesupport (= 5.2.4.3) + rails (5.2.4.4) + actioncable (= 5.2.4.4) + actionmailer (= 5.2.4.4) + actionpack (= 5.2.4.4) + actionview (= 5.2.4.4) + activejob (= 5.2.4.4) + activemodel (= 5.2.4.4) + activerecord (= 5.2.4.4) + activestorage (= 5.2.4.4) + activesupport (= 5.2.4.4) bundler (>= 1.3.0) - railties (= 5.2.4.3) + railties (= 5.2.4.4) sprockets-rails (>= 2.0.0) - rails-controller-testing (1.0.4) - actionpack (>= 5.0.1.x) - actionview (>= 5.0.1.x) - activesupport (>= 5.0.1.x) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) @@ -466,9 +466,9 @@ GEM rails-i18n (5.1.3) i18n (>= 0.7, < 2) railties (>= 5.0, < 6) - railties (5.2.4.3) - actionpack (= 5.2.4.3) - activesupport (= 5.2.4.3) + railties (5.2.4.4) + actionpack (= 5.2.4.4) + activesupport (= 5.2.4.4) method_source rake (>= 0.8.7) thor (>= 0.19.0, < 2.0) @@ -484,7 +484,7 @@ GEM redis (>= 3.0, < 5.0) recaptcha (5.2.1) json - redis (4.2.1) + redis (4.2.2) redis-session-store (0.11.3) actionpack (>= 3, < 7) redis (>= 3, < 5) @@ -523,16 +523,16 @@ GEM rspec-mocks (~> 3.9.0) rspec-support (~> 3.9.0) rspec-support (3.9.3) - rubocop (0.85.1) + rubocop (0.91.0) parallel (~> 1.10) - parser (>= 2.7.0.1) + parser (>= 2.7.1.1) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.7) rexml - rubocop-ast (>= 0.0.3) + rubocop-ast (>= 0.4.0, < 1.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 2.0) - rubocop-ast (0.3.0) + rubocop-ast (0.4.1) parser (>= 2.7.1.4) rubocop-rails (2.5.2) activesupport @@ -648,8 +648,8 @@ GEM coercible (~> 1.0) descendants_tracker (~> 0.0, >= 0.0.3) equalizer (~> 0.0, >= 0.0.9) - warden (1.2.8) - rack (>= 2.0.6) + warden (1.2.9) + rack (>= 2.0.9) webauthn (2.1.0) awrence (~> 1.1) bindata (~> 2.4) @@ -670,7 +670,7 @@ GEM activesupport (>= 4.2) rack-proxy (>= 0.6.1) railties (>= 4.2) - websocket-driver (0.7.2) + websocket-driver (0.7.3) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xmldsig (0.6.6) @@ -711,7 +711,7 @@ DEPENDENCIES codeclimate-test-reporter derailed (>= 0.1.0) device_detector - devise (~> 4.7.1) + devise (~> 4.7.2) dotiw (>= 4.0.1) email_spec exception_notification (>= 4.4.0) @@ -758,7 +758,7 @@ DEPENDENCIES rack-test (>= 1.1.0) rack-timeout rack_session_access (>= 0.2.0) - rails (~> 5.2.4, >= 5.2.4.3) + rails (~> 5.2.4, >= 5.2.4.4) rails-controller-testing (>= 1.0.4) rails-erd (>= 1.6.0) raise-if-root @@ -768,7 +768,7 @@ DEPENDENCIES rotp (~> 3.3.1) rqrcode rspec-rails (~> 3.9, >= 3.9.1) - rubocop (~> 0.85.0) + rubocop (~> 0.91.0) rubocop-rails (>= 2.5.2) ruby-progressbar ruby-saml diff --git a/app/assets/images/user-access.svg b/app/assets/images/user-access.svg index 7482ff66a03..6f9fff51b63 100644 --- a/app/assets/images/user-access.svg +++ b/app/assets/images/user-access.svg @@ -73,10 +73,7 @@ - - - - + @@ -154,5 +151,9 @@ + + + + diff --git a/app/assets/stylesheets/components/_banner.scss b/app/assets/stylesheets/components/_banner.scss index de441e9b5f1..65815055245 100644 --- a/app/assets/stylesheets/components/_banner.scss +++ b/app/assets/stylesheets/components/_banner.scss @@ -32,7 +32,7 @@ } .close-banner-how-you-know { - background: url(#{$image-path}/close-primary.svg) top left no-repeat; + background: url(image-path('#{$image-path}/close-primary.svg')) top left no-repeat; background-size: contain; border: 0; display: inline-block; diff --git a/app/assets/stylesheets/components/_nav.scss b/app/assets/stylesheets/components/_nav.scss index 7fdb70533b2..917829b909c 100644 --- a/app/assets/stylesheets/components/_nav.scss +++ b/app/assets/stylesheets/components/_nav.scss @@ -21,3 +21,40 @@ img { height: 17px; } } } + +.sidenav-mobile { + @include at-media('desktop') { + display: none; + } + + .usa-nav__close { + @include add-background-svg('close-blue-60v-alt'); + @include u-square(6); + background-position: center center; + background-repeat: no-repeat; + } +} + +.sidenav { + display: none; + + @include at-media('desktop') { + display: block; + } +} + +.authnav-greeting { + display: none; + + @include at-media('desktop') { + display: block; + } +} + +.top-banner { + display: none; + + @include at-media('desktop') { + display: flex; + } +} diff --git a/app/assets/stylesheets/components/_profile-section.scss b/app/assets/stylesheets/components/_profile-section.scss index fb84149b984..26400ae795b 100644 --- a/app/assets/stylesheets/components/_profile-section.scss +++ b/app/assets/stylesheets/components/_profile-section.scss @@ -1,9 +1,9 @@ .profile-info-box { border: 0; - border-bottom: $border-width solid $border-color; border-radius: 0; margin-bottom: 0; overflow: hidden; + padding: $space-3; .bg-lightest-blue img { margin-top: -2px; @@ -11,8 +11,27 @@ } } -@media #{$breakpoint-sm} { +@include at-media('mobile') { .profile-info-box { + border-radius: $border-radius-md; + margin-bottom: $space-3; + } +} + +.events-info-box { + border: $border-width solid $border-color; + border-radius: 0; + margin-bottom: 0; + overflow: hidden; + + .bg-lightest-blue img { + margin-top: -2px; + vertical-align: middle; + } +} + +@include at-media('mobile') { + .events-info-box { border: $border-width solid $border-color; border-radius: $border-radius-md; margin-bottom: $space-3; diff --git a/app/assets/stylesheets/components/_util.scss b/app/assets/stylesheets/components/_util.scss index 40e95b730eb..b215d5fb4ff 100644 --- a/app/assets/stylesheets/components/_util.scss +++ b/app/assets/stylesheets/components/_util.scss @@ -69,7 +69,6 @@ // Temporary Classes for Overriding during design system migration - .border-top { border-top: 1px solid $border-color; } diff --git a/app/controllers/accounts/connected_accounts_controller.rb b/app/controllers/accounts/connected_accounts_controller.rb new file mode 100644 index 00000000000..6c2a5db033f --- /dev/null +++ b/app/controllers/accounts/connected_accounts_controller.rb @@ -0,0 +1,17 @@ +module Accounts + class ConnectedAccountsController < ApplicationController + include RememberDeviceConcern + before_action :confirm_two_factor_authenticated + + layout 'account_side_nav' + + def show + @view_model = AccountShow.new( + decrypted_pii: nil, + personal_key: flash[:personal_key], + decorated_user: current_user.decorate, + locked_for_session: pii_locked_for_session?(current_user), + ) + end + end +end diff --git a/app/controllers/accounts/history_controller.rb b/app/controllers/accounts/history_controller.rb new file mode 100644 index 00000000000..ff60ea139bf --- /dev/null +++ b/app/controllers/accounts/history_controller.rb @@ -0,0 +1,17 @@ +module Accounts + class HistoryController < ApplicationController + include RememberDeviceConcern + before_action :confirm_two_factor_authenticated + + layout 'account_side_nav' + + def show + @view_model = AccountShow.new( + decrypted_pii: nil, + personal_key: flash[:personal_key], + decorated_user: current_user.decorate, + locked_for_session: pii_locked_for_session?(current_user), + ) + end + end +end diff --git a/app/controllers/accounts/two_factor_authentication_controller.rb b/app/controllers/accounts/two_factor_authentication_controller.rb new file mode 100644 index 00000000000..ba93183a373 --- /dev/null +++ b/app/controllers/accounts/two_factor_authentication_controller.rb @@ -0,0 +1,18 @@ +module Accounts + class TwoFactorAuthenticationController < ApplicationController + include RememberDeviceConcern + before_action :confirm_two_factor_authenticated + + layout 'account_side_nav' + + def show + session[:account_redirect_path] = account_two_factor_authentication_path + @view_model = AccountShow.new( + decrypted_pii: nil, + personal_key: flash[:personal_key], + decorated_user: current_user.decorate, + locked_for_session: pii_locked_for_session?(current_user), + ) + end + end +end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index e60f20caeee..e327e8dbf43 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -2,10 +2,11 @@ class AccountsController < ApplicationController include RememberDeviceConcern before_action :confirm_two_factor_authenticated - layout 'card_wide' + layout 'account_side_nav' def show analytics.track_event(Analytics::ACCOUNT_VISIT) + session[:account_redirect_path] = account_path cacher = Pii::Cacher.new(current_user, user_session) @view_model = AccountShow.new( decrypted_pii: cacher.fetch, diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f2d8f9a89b5..ebb9b160361 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -174,7 +174,7 @@ def after_mfa_setup_path elsif user_needs_to_reactivate_account? reactivate_account_url else - after_sign_in_path_for(current_user) + session[:account_redirect_path] || after_sign_in_path_for(current_user) end end diff --git a/app/controllers/concerns/saml_idp_auth_concern.rb b/app/controllers/concerns/saml_idp_auth_concern.rb index c795a0f8020..06b43fef6bf 100644 --- a/app/controllers/concerns/saml_idp_auth_concern.rb +++ b/app/controllers/concerns/saml_idp_auth_concern.rb @@ -1,4 +1,3 @@ -# rubocop:disable Metrics/ModuleLength module SamlIdpAuthConcern extend ActiveSupport::Concern extend Forwardable @@ -151,4 +150,3 @@ def request_url url.to_s end end -# rubocop:enable Metrics/ModuleLength diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index ec170846ab5..b57e8649a23 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,7 +1,7 @@ class EventsController < ApplicationController include RememberDeviceConcern before_action :confirm_two_factor_authenticated - layout 'card_wide' + layout 'no_card' EVENTS_PAGE_SIZE = 25 diff --git a/app/controllers/idv/capture_doc_controller.rb b/app/controllers/idv/capture_doc_controller.rb index 6d136275565..d9a4874746f 100644 --- a/app/controllers/idv/capture_doc_controller.rb +++ b/app/controllers/idv/capture_doc_controller.rb @@ -20,11 +20,11 @@ def index # action. # if FeatureManagement.document_capture_step_enabled? - @flow.flow_session['Idv::Steps::MobileFrontImageStep'] = true - @flow.flow_session['Idv::Steps::CaptureMobileBackImageStep'] = true - @flow.flow_session['Idv::Steps::SelfieStep'] = true + flow.mark_step_complete(:mobile_front_image) + flow.mark_step_complete(:capture_mobile_back_image) + flow.mark_step_complete(:selfie) else - @flow.flow_session['Idv::Steps::DocumentCaptureStep'] = true + flow.mark_step_complete(:document_capture) end redirect_to_step(next_step) end @@ -69,12 +69,19 @@ def process_result(result) reset_session session[:doc_capture_user_id] = result.extra[:for_user_id] session[:document_capture_session_uuid] = document_capture_session_uuid + update_sp_session_with_result(result) else flash[:error] = t('errors.capture_doc.invalid_link') redirect_to root_url end end + def update_sp_session_with_result(result) + session[:sp] ||= {} + session[:sp][:ial2_strict] = result.extra[:ial2_strict] + session[:sp][:issuer] = result.extra[:sp_issuer] + end + def token params[:token] end diff --git a/app/controllers/idv/image_uploads_controller.rb b/app/controllers/idv/image_uploads_controller.rb index acec62f4a9f..552c6d47bf7 100644 --- a/app/controllers/idv/image_uploads_controller.rb +++ b/app/controllers/idv/image_uploads_controller.rb @@ -9,20 +9,34 @@ class ImageUploadsController < ApplicationController def create form_response = image_form.submit + analytics.track_event(Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, form_response.to_h) + if form_response.success? - doc_response = doc_auth_client.post_images( + client_response = doc_auth_client.post_images( front_image: image_form.front.read, back_image: image_form.back.read, selfie_image: image_form.selfie&.read, liveness_checking_enabled: liveness_checking_enabled?, ) - store_pii(doc_response) if doc_response.success? + analytics.track_event( + Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + client_response.to_h, + ) - render_form_response(doc_response) + store_pii(client_response) if client_response.success? + status = :bad_request unless client_response.success? else - render_form_response(form_response) + status = image_form.status end + + presenter = ImageUploadResponsePresenter.new( + form: image_form, + form_response: client_response || form_response, + ) + + render json: presenter, + status: status || :ok end private @@ -42,21 +56,6 @@ def store_pii(doc_response) image_form.document_capture_session.store_result_from_response(doc_response) end - def render_form_response(form_response) - if form_response.success? - render json: { - success: true, - } - else - errors = form_response.errors.flat_map do |key, errs| - Array(errs).map { |err| { field: key, message: err } } - end - - render json: form_response.to_h.merge(errors: errors), - status: :bad_request - end - end - def doc_auth_client @doc_auth_client ||= DocAuth::Client.client end diff --git a/app/controllers/sign_up/completions_controller.rb b/app/controllers/sign_up/completions_controller.rb index 129c927f3d5..15d2a4f1cfd 100644 --- a/app/controllers/sign_up/completions_controller.rb +++ b/app/controllers/sign_up/completions_controller.rb @@ -117,6 +117,7 @@ def displayable_attributes email: email, verified_at: verified_at, x509_subject: current_user.piv_cac_configurations.first&.x509_dn_uuid, + x509_issuer: current_user.piv_cac_configurations.first&.x509_issuer, } end @@ -144,6 +145,7 @@ def pii_to_displayable_attributes email: email, verified_at: verified_at, x509_subject: current_user.piv_cac_configurations.first&.x509_dn_uuid, + x509_issuer: current_user.piv_cac_configurations.first&.x509_issuer, } end end diff --git a/app/controllers/test/telephony_controller.rb b/app/controllers/test/telephony_controller.rb index 01b6264b6a3..3ed7732836c 100644 --- a/app/controllers/test/telephony_controller.rb +++ b/app/controllers/test/telephony_controller.rb @@ -1,6 +1,6 @@ module Test class TelephonyController < ApplicationController - layout 'card_wide' + layout 'no_card' before_action :render_not_found_in_production diff --git a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb index 83d2bb6bf37..35b9a819215 100644 --- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb +++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb @@ -42,6 +42,7 @@ def handle_valid_piv_cac clear_piv_cac_nonce save_piv_cac_information( subject: piv_cac_verfication_form.x509_dn, + issuer: piv_cac_verfication_form.x509_issuer, presented: true, ) diff --git a/app/controllers/users/backup_code_setup_controller.rb b/app/controllers/users/backup_code_setup_controller.rb index 5f2da6fef95..65840b4ba61 100644 --- a/app/controllers/users/backup_code_setup_controller.rb +++ b/app/controllers/users/backup_code_setup_controller.rb @@ -45,7 +45,7 @@ def delete current_user.backup_code_configurations.destroy_all flash[:success] = t('notices.backup_codes_deleted') revoke_remember_device(current_user) - redirect_to account_url + redirect_to account_two_factor_authentication_path end private diff --git a/app/controllers/users/piv_cac_authentication_setup_controller.rb b/app/controllers/users/piv_cac_authentication_setup_controller.rb index 47b626f432d..711c76c7ae5 100644 --- a/app/controllers/users/piv_cac_authentication_setup_controller.rb +++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb @@ -28,7 +28,7 @@ def delete clear_piv_cac_information create_user_event(:piv_cac_disabled) flash[:success] = t('notices.piv_cac_disabled') - redirect_to account_url + redirect_to account_two_factor_authentication_path end def submit_new_piv_cac @@ -94,6 +94,7 @@ def process_valid_submission flash[:success] = t('notices.piv_cac_configured') save_piv_cac_information( subject: user_piv_cac_form.x509_dn, + issuer: user_piv_cac_form.x509_issuer, presented: true, ) create_user_event(:piv_cac_enabled) @@ -118,8 +119,8 @@ def process_invalid_submission end def authorize_piv_cac_disable - return redirect_to account_url unless piv_cac_enabled? && - MfaPolicy.new(current_user).multiple_factors_enabled? + return if piv_cac_enabled? && MfaPolicy.new(current_user).multiple_factors_enabled? + redirect_to account_two_factor_authentication_path end def good_nickname @@ -128,7 +129,8 @@ def good_nickname end def cap_piv_cac_count - redirect_to account_url if Figaro.env.max_piv_cac_per_account.to_i <= current_cac_count + return unless Figaro.env.max_piv_cac_per_account.to_i <= current_cac_count + redirect_to account_two_factor_authentication_path end def current_cac_count diff --git a/app/controllers/users/piv_cac_login_controller.rb b/app/controllers/users/piv_cac_login_controller.rb index 2111b66a62f..a814683db30 100644 --- a/app/controllers/users/piv_cac_login_controller.rb +++ b/app/controllers/users/piv_cac_login_controller.rb @@ -65,6 +65,7 @@ def process_valid_submission save_piv_cac_information( subject: piv_cac_login_form.x509_dn, + issuer: piv_cac_login_form.x509_issuer, presented: true, ) diff --git a/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb b/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb index 59b898568d0..f102797c13c 100644 --- a/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb +++ b/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb @@ -76,6 +76,7 @@ def process_valid_submission session.delete(:needs_to_setup_piv_cac_after_sign_in) save_piv_cac_information( subject: user_piv_cac_form.x509_dn, + issuer: user_piv_cac_form.x509_issuer, presented: true, ) create_user_event(:piv_cac_enabled) diff --git a/app/controllers/users/service_provider_revoke_controller.rb b/app/controllers/users/service_provider_revoke_controller.rb index 58d2bf0bb9f..0be4ef9c891 100644 --- a/app/controllers/users/service_provider_revoke_controller.rb +++ b/app/controllers/users/service_provider_revoke_controller.rb @@ -3,7 +3,7 @@ class ServiceProviderRevokeController < ApplicationController before_action :confirm_two_factor_authenticated rescue_from ActiveRecord::RecordNotFound do - redirect_to account_url + redirect_to account_connected_accounts_path end def show @@ -19,7 +19,7 @@ def destroy RevokeServiceProviderConsent.new(identity).call analytics.track_event(Analytics::SP_REVOKE_CONSENT_REVOKED, issuer: @service_provider.issuer) - redirect_to account_url + redirect_to account_connected_accounts_path end private diff --git a/app/controllers/users/totp_setup_controller.rb b/app/controllers/users/totp_setup_controller.rb index fdfb0f004f3..83f3ad5be95 100644 --- a/app/controllers/users/totp_setup_controller.rb +++ b/app/controllers/users/totp_setup_controller.rb @@ -34,7 +34,7 @@ def confirm def disable process_successful_disable if MfaPolicy.new(current_user).multiple_factors_enabled? - redirect_to account_url + redirect_to account_two_factor_authentication_path end private @@ -124,7 +124,8 @@ def new_totp_secret end def cap_auth_app_count - redirect_to account_url if Figaro.env.max_auth_apps_per_account.to_i <= current_auth_app_count + return unless Figaro.env.max_auth_apps_per_account.to_i <= current_auth_app_count + redirect_to account_two_factor_authentication_path end def current_auth_app_count diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index 62c7d409c03..4296a6e1d73 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -34,7 +34,7 @@ def delete else handle_failed_delete end - redirect_to account_url + redirect_to account_two_factor_authentication_path end def show_delete @@ -95,6 +95,7 @@ def process_valid_webauthn handle_remember_device Funnel::Registration::AddMfa.call(current_user.id, 'webauthn') flash[:success] = t('notices.webauthn_configured') + user_session[:auth_method] = 'webauthn' redirect_to after_mfa_setup_path end @@ -109,7 +110,7 @@ def process_invalid_webauthn(form) render :new else flash[:error] = t('errors.webauthn_setup.general_error') - redirect_to account_url + redirect_to account_two_factor_authentication_path end end diff --git a/app/decorators/device_decorator.rb b/app/decorators/device_decorator.rb index fd9973a1b95..491a4cd39b8 100644 --- a/app/decorators/device_decorator.rb +++ b/app/decorators/device_decorator.rb @@ -1,10 +1,6 @@ DeviceDecorator = Struct.new(:device) do delegate :nice_name, :last_used_at, :id, to: :device - def device_partial - 'accounts/device_item' - end - def last_sign_in_location_and_ip I18n.t('account.index.sign_in_location_and_ip', location: last_location, ip: device.last_ip) end diff --git a/app/forms/concerns/piv_cac_form_helpers.rb b/app/forms/concerns/piv_cac_form_helpers.rb index 4d35277d89c..30394381c02 100644 --- a/app/forms/concerns/piv_cac_form_helpers.rb +++ b/app/forms/concerns/piv_cac_form_helpers.rb @@ -21,6 +21,7 @@ def not_error_token else self.x509_dn_uuid = @data['uuid'] self.x509_dn = @data['subject'] + self.x509_issuer = @data['issuer'] true end end diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 98f421a7209..ed76eb80c2c 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -15,6 +15,7 @@ class ApiImageUploadForm validates_presence_of :selfie, if: :liveness_checking_enabled? validate :validate_images + validate :throttle_if_rate_limited def initialize(params, liveness_checking_enabled:) @params = params @@ -22,13 +23,27 @@ def initialize(params, liveness_checking_enabled:) end def submit + throttled_else_increment + FormResponse.new( success: valid?, errors: errors.messages, - extra: {}, + extra: { + remaining_attempts: remaining_attempts, + }, ) end + def status + return :ok if valid? + return :too_many_requests if errors.key?(:limit) + :bad_request + end + + def remaining_attempts + Throttler::RemainingCount.call(document_capture_session.user_id, :idv_acuant) + end + def liveness_checking_enabled? @liveness_checking_enabled end @@ -66,6 +81,18 @@ def self.human_attribute_name(attr, options = {}) attr_reader :params + def throttle_if_rate_limited + return unless @throttled + errors.add(:limit, t('errors.doc_auth.acuant_throttle')) + end + + def throttled_else_increment + @throttled = Throttler::IsThrottledElseIncrement.call( + document_capture_session.user_id, + :idv_acuant, + ) + end + def validate_images IMAGE_KEYS.each do |image_key| validate_image(image_key) if params[image_key] @@ -75,16 +102,8 @@ def validate_images def validate_image(image_key) file = params[image_key] - unless file.respond_to?(:content_type) - errors.add(image_key, t('doc_auth.errors.not_a_file')) - return - end - - data = file.read - file.rewind - - return if file.content_type.start_with?('image/') && data.present? - errors.add(image_key, t('doc_auth.errors.must_be_image')) + return if file.respond_to?(:read) + errors.add(image_key, t('doc_auth.errors.not_a_file')) end end end diff --git a/app/forms/piv_cac_proofing_form.rb b/app/forms/piv_cac_proofing_form.rb index 09093f94015..44f9fb24090 100644 --- a/app/forms/piv_cac_proofing_form.rb +++ b/app/forms/piv_cac_proofing_form.rb @@ -2,8 +2,8 @@ class PivCacProofingForm include ActiveModel::Model include PivCacFormHelpers - attr_accessor :x509_dn_uuid, :x509_dn, :token, :error_type, :nonce, :user, :key_id, :first_name, - :last_name, :cn + attr_accessor :x509_dn_uuid, :x509_dn, :x509_issuer, :token, :error_type, :nonce, :user, :key_id, + :first_name, :last_name, :cn validates :token, presence: true validates :nonce, presence: true diff --git a/app/forms/security_event_form.rb b/app/forms/security_event_form.rb index 90627e0315c..cdda0dad373 100644 --- a/app/forms/security_event_form.rb +++ b/app/forms/security_event_form.rb @@ -45,8 +45,6 @@ def submit if event_type == SecurityEvent::AUTHORIZATION_FRAUD_DETECTED ResetUserPassword.new(user: user).call - UserEventCreator.new(current_user: user). - create_out_of_band_user_event(:password_invalidated) end end diff --git a/app/forms/user_piv_cac_login_form.rb b/app/forms/user_piv_cac_login_form.rb index 592fca2cb11..f6c2844ff57 100644 --- a/app/forms/user_piv_cac_login_form.rb +++ b/app/forms/user_piv_cac_login_form.rb @@ -2,7 +2,7 @@ class UserPivCacLoginForm include ActiveModel::Model include PivCacFormHelpers - attr_accessor :x509_dn_uuid, :x509_dn, :token, :error_type, :nonce, :user, :key_id + attr_accessor :x509_dn_uuid, :x509_dn, :x509_issuer, :token, :error_type, :nonce, :user, :key_id validates :token, presence: true validates :nonce, presence: true diff --git a/app/forms/user_piv_cac_setup_form.rb b/app/forms/user_piv_cac_setup_form.rb index deae70611ca..be6d59ac436 100644 --- a/app/forms/user_piv_cac_setup_form.rb +++ b/app/forms/user_piv_cac_setup_form.rb @@ -2,8 +2,8 @@ class UserPivCacSetupForm include ActiveModel::Model include PivCacFormHelpers - attr_accessor :x509_dn_uuid, :x509_dn, :token, :user, :nonce, :error_type, :name, :key_id, - :piv_cac_required + attr_accessor :x509_dn_uuid, :x509_dn, :x509_issuer, :token, :user, :nonce, :error_type, :name, + :key_id, :piv_cac_required attr_reader :name_taken validates :token, presence: true @@ -26,7 +26,7 @@ def submit private def process_valid_submission - Db::PivCacConfiguration::Create.call(user, x509_dn_uuid, @name) + Db::PivCacConfiguration::Create.call(user, x509_dn_uuid, @name, x509_issuer) true rescue PG::UniqueViolation self.error_type = 'piv_cac.already_associated' @@ -40,6 +40,7 @@ def valid_submission? def piv_cac_not_already_associated self.x509_dn_uuid = @data['uuid'] self.x509_dn = @data['subject'] + self.x509_issuer = @data['issuer'] if Db::PivCacConfiguration::FindUserByX509.call(x509_dn_uuid) self.error_type = 'piv_cac.already_associated' false diff --git a/app/forms/user_piv_cac_verification_form.rb b/app/forms/user_piv_cac_verification_form.rb index 3df1d79b2bc..9dfd26d0040 100644 --- a/app/forms/user_piv_cac_verification_form.rb +++ b/app/forms/user_piv_cac_verification_form.rb @@ -2,7 +2,7 @@ class UserPivCacVerificationForm include ActiveModel::Model include PivCacFormHelpers - attr_accessor :x509_dn_uuid, :x509_dn, :token, :error_type, :nonce, :user, :key_id, + attr_accessor :x509_dn_uuid, :x509_dn, :x509_issuer, :token, :error_type, :nonce, :user, :key_id, :piv_cac_required validates :token, presence: true diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index dda1b242f49..c778f111655 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -3,10 +3,6 @@ def title(title) content_for(:title) { title } end - def card_cls(cls) - content_for(:card_cls) { cls } - end - def background_cls(cls) content_for(:background_cls) { cls } end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index f884d855796..b3be9f0002f 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -94,4 +94,11 @@ def international_phone_codes_data(code_data) country_name: code_data['name'], } end + + def validated_form_for(record, options = {}, &block) + options[:data] ||= {} + options[:data][:validate] = true + javascript_pack_tag_once('form-validation') + simple_form_for(record, options, &block) + end end diff --git a/app/helpers/script_helper.rb b/app/helpers/script_helper.rb new file mode 100644 index 00000000000..5455bc10079 --- /dev/null +++ b/app/helpers/script_helper.rb @@ -0,0 +1,16 @@ +require 'set' + +# rubocop:disable Rails/HelperInstanceVariable +module ScriptHelper + include Webpacker::Helper + + def javascript_pack_tag_once(name) + @scripts ||= Set.new + @scripts.add(name) + end + + def render_javascript_pack_once_tags + javascript_pack_tag(*@scripts) if @scripts + end +end +# rubocop:enable Rails/HelperInstanceVariable diff --git a/app/javascript/app/components/focus-trap-proxy.js b/app/javascript/app/components/focus-trap-proxy.js deleted file mode 100644 index 8c65257ec47..00000000000 --- a/app/javascript/app/components/focus-trap-proxy.js +++ /dev/null @@ -1,51 +0,0 @@ -import createFocusTrap from 'focus-trap'; - -function FocusTrapProxy() { - const focusables = []; - let activated = []; - - return function makeTrap(el, options = {}) { - const ownTrap = createFocusTrap(el, options); - - focusables.push(ownTrap); - - return { - activate() { - focusables.forEach((trap) => trap.deactivate()); - - activated.push(ownTrap); - - ownTrap.activate(); - - return ownTrap; - }, - - deactivate(opts = {}) { - const deactivatedTrap = ownTrap.deactivate(opts); - - // `deactivate` will return a valid trap object if it is available to be - // deactivated. If not, it returns a falsey value. If nothing was deactivated, - // bail out. - if (!deactivatedTrap) { - return false; - } - - activated = activated.filter((activatedTrap) => activatedTrap !== ownTrap); - - if (activated.length) { - activated[activated.length - 1].activate(); - } - - return deactivatedTrap; - }, - - pause() { - ownTrap.pause(); - }, - }; - }; -} - -const focusTrapProxy = FocusTrapProxy.call(FocusTrapProxy); - -export default focusTrapProxy; diff --git a/app/javascript/app/components/index.js b/app/javascript/app/components/index.js index 69c77cfcefb..c4a4bc5facb 100644 --- a/app/javascript/app/components/index.js +++ b/app/javascript/app/components/index.js @@ -1,10 +1,4 @@ -import focusTrapProxy from './focus-trap-proxy'; -import modal from './modal'; +import Modal from './modal'; window.LoginGov = window.LoginGov || {}; -const { LoginGov } = window; -const trapModal = modal(focusTrapProxy); - -LoginGov.Modal = trapModal; - -export { trapModal as Modal }; +window.LoginGov.Modal = Modal; diff --git a/app/javascript/app/components/modal.js b/app/javascript/app/components/modal.js index 6a335cd6ba9..5dedaa163a8 100644 --- a/app/javascript/app/components/modal.js +++ b/app/javascript/app/components/modal.js @@ -1,4 +1,5 @@ -import 'classlist.js'; +import 'classlist-polyfill'; +import { createFocusTrap } from 'focus-trap'; import Events from '../utils/events'; const STATES = { @@ -6,43 +7,41 @@ const STATES = { SHOW: 'show', }; -function modal(focusTrap) { - return class extends Events { - constructor(options) { - super(); +class Modal extends Events { + constructor(options) { + super(); - this.el = document.querySelector(options.el); - this.shown = false; - this.trap = focusTrap(this.el, { escapeDeactivates: false }); - } - - toggle() { - if (this.shown) { - this.hide(); - } else { - this.show(); - } - } - - show(target) { - this.setElementVisibility(target, true); - this.emit(STATES.SHOW); - } - - hide(target) { - this.setElementVisibility(target, false); - this.emit(STATES.HIDE); - } - - setElementVisibility(target = null, showing) { - const el = target || this.el; + this.el = document.querySelector(options.el); + this.shown = false; + this.trap = createFocusTrap(this.el, { escapeDeactivates: false }); + } - this.shown = showing; - el.classList[showing ? 'remove' : 'add']('display-none'); - document.body.classList[showing ? 'add' : 'remove']('modal-open'); - this.trap[showing ? 'activate' : 'deactivate'](); + toggle() { + if (this.shown) { + this.hide(); + } else { + this.show(); } - }; + } + + show(target) { + this.setElementVisibility(target, true); + this.emit(STATES.SHOW); + } + + hide(target) { + this.setElementVisibility(target, false); + this.emit(STATES.HIDE); + } + + setElementVisibility(target = null, showing) { + const el = target || this.el; + + this.shown = showing; + el.classList[showing ? 'remove' : 'add']('display-none'); + document.body.classList[showing ? 'add' : 'remove']('modal-open'); + this.trap[showing ? 'activate' : 'deactivate'](); + } } -export default modal; +export default Modal; diff --git a/app/javascript/app/form-validation.js b/app/javascript/app/form-validation.js deleted file mode 100644 index 19d0e192f00..00000000000 --- a/app/javascript/app/form-validation.js +++ /dev/null @@ -1,133 +0,0 @@ -import 'classlist.js'; - -const { I18n } = window.LoginGov; - -document.addEventListener('DOMContentLoaded', () => { - if (document.body.classList.contains('js-skip-form-validation')) { - return; - } - - const forms = document.querySelectorAll('form'); - - function addListenerMulti(el, events, fn) { - events.split(' ').forEach((e) => el.addEventListener(e, fn, false)); - } - - if (forms.length !== 0) { - [].forEach.call(forms, function (form) { - const buttons = form.querySelectorAll('[type="submit"]'); - form.addEventListener( - 'submit', - function () { - if (buttons.length !== 0) { - [].forEach.call(buttons, function (button) { - button.disabled = true; - }); - } - const submitSpinner = document.getElementById('submit-spinner'); - if (submitSpinner) { - submitSpinner.className = ''; - } - }, - false, - ); - const elements = form.querySelectorAll('input'); - if (elements.length !== 0) { - [].forEach.call(elements, function (input) { - input.addEventListener('input', function () { - if (buttons.length !== 0 && input.checkValidity()) { - [].forEach.call(buttons, function (button) { - if (button.disabled && !button.classList.contains('no-auto-enable')) { - button.disabled = false; - } - }); - } - }); - }); - } - - const conditionalRequiredInputs = form.querySelectorAll('input[data-required-if-checked]'); - - if (conditionalRequiredInputs.length !== 0) { - [].forEach.call(conditionalRequiredInputs, function (drivenInput) { - const selector = drivenInput.getAttribute('data-required-if-checked'); - const drivingElement = form.querySelector(selector); - - if (drivingElement) { - const otherInputs = form.querySelectorAll(`input[name="${drivingElement.name}"]`); - const handler = function () { - drivenInput.required = this === drivingElement; - return true; - }; - if (otherInputs.count !== 0) { - [].forEach.call(otherInputs, function (input) { - input.addEventListener('click', handler); - }); - } - drivenInput.addEventListener('focus', function () { - drivingElement.click(); - return true; - }); - } - }); - } - - const conditionalVisibleInputs = form.querySelectorAll('input[data-visible-if-checked]'); - - if (conditionalVisibleInputs.length !== 0) { - [].forEach.call(conditionalVisibleInputs, function (drivenInput) { - const selector = drivenInput.getAttribute('data-visible-if-checked'); - const drivingElement = form.querySelector(selector); - - if (drivingElement) { - const otherInputs = form.querySelectorAll(`input[name="${drivingElement.name}"]`); - - const handler = function () { - const visible = this === drivingElement; - if (drivenInput.classList) { - drivenInput.classList.toggle('hidden', !visible); - } else if (visible) { - drivenInput.className = drivenInput.className.replace(/\bhidden\b/g, ''); - } else { - drivenInput.className = `${drivenInput.className} hidden`; - } - - drivenInput.required = this === drivingElement; - return true; - }; - - if (otherInputs.count !== 0) { - [].forEach.call(otherInputs, function (input) { - input.addEventListener('click', handler); - }); - } - } - }); - } - - const inputs = form.querySelectorAll('.field'); - - if (inputs.length !== 0) { - [].forEach.call(inputs, function (input) { - const types = ['dob', 'personal-key', 'ssn', 'state_id_number', 'zipcode']; - - addListenerMulti(input, 'input invalid', (e) => { - e.target.setCustomValidity(''); - - if (e.target.validity.valueMissing) { - e.target.setCustomValidity(I18n.t('simple_form.required.text')); - } else if (e.target.validity.patternMismatch) { - types.forEach(function (type) { - if (e.target.classList.contains(type)) { - e.target.setCustomValidity( - I18n.t(`idv.errors.pattern_mismatch.${I18n.key(type)}`), - ); - } - }); - } - }); - }); - } - }); - } -}); diff --git a/app/javascript/app/i18n-dropdown.js b/app/javascript/app/i18n-dropdown.js index 3f1c62881f9..12e43e9dd18 100644 --- a/app/javascript/app/i18n-dropdown.js +++ b/app/javascript/app/i18n-dropdown.js @@ -1,4 +1,4 @@ -import 'classlist.js'; +import 'classlist-polyfill'; document.addEventListener('DOMContentLoaded', () => { const mobileLink = document.querySelector('.i18n-mobile-toggle > a'); diff --git a/app/javascript/app/radio-btn.js b/app/javascript/app/radio-btn.js index af144cd0853..a09b2dc7476 100644 --- a/app/javascript/app/radio-btn.js +++ b/app/javascript/app/radio-btn.js @@ -1,4 +1,4 @@ -import 'classlist.js'; +import 'classlist-polyfill'; function clearHighlight(name) { const radioGroup = document.querySelectorAll(`input[name='${name}']`); diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index 05f1585b92a..8199f540f59 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -15,9 +15,27 @@ import Button from './button'; import useI18n from '../hooks/use-i18n'; import DeviceContext from '../context/device'; import FileBase64CacheContext from '../context/file-base64-cache'; +import UploadContext from '../context/upload'; +import useIfStillMounted from '../hooks/use-if-still-mounted'; /** @typedef {import('react').ReactNode} ReactNode */ +/** + * @typedef AcuantPassiveLiveness + * + * @prop {(callback:(nextImageData:string)=>void)=>void} startSelfieCapture Start liveness capture. + */ + +/** + * @typedef AcuantGlobals + * + * @prop {AcuantPassiveLiveness} AcuantPassiveLiveness Acuant Passive Liveness API. + */ + +/** + * @typedef {typeof window & AcuantGlobals} AcuantGlobal + */ + /** * @typedef AcuantCaptureProps * @@ -25,12 +43,9 @@ import FileBase64CacheContext from '../context/file-base64-cache'; * @prop {string=} bannerText Optional banner text to show in file input. * @prop {Blob?=} value Current value. * @prop {(nextValue:Blob?)=>void} onChange Callback receiving next value on change. - * @prop {'user'|'environment'=} capture Facing mode of capture. If capture is not specified and a - * camera is supported, defaults to the Acuant environment camera capture. + * @prop {'user'=} capture Facing mode of capture. If capture is not specified and a camera is + * supported, defaults to the Acuant environment camera capture. * @prop {string=} className Optional additional class names. - * @prop {number=} minimumGlareScore Minimum glare score to be considered acceptable. - * @prop {number=} minimumSharpnessScore Minimum sharpness score to be considered acceptable. - * @prop {number=} minimumFileSize Minimum file size (in bytes) to be considered acceptable. * @prop {boolean=} allowUpload Whether to allow file upload. Defaults to `true`. * @prop {ReactNode=} errorMessage Error to show. */ @@ -40,24 +55,14 @@ import FileBase64CacheContext from '../context/file-base64-cache'; * * @type {number} */ -const DEFAULT_ACCEPTABLE_GLARE_SCORE = 50; +const ACCEPTABLE_GLARE_SCORE = 50; /** * The minimum sharpness score value to be considered acceptable. * * @type {number} */ -const DEFAULT_ACCEPTABLE_SHARPNESS_SCORE = 50; - -/** - * The minimum file size (bytes) for an image to be considered acceptable. - * - * @type {number} - */ -const DEFAULT_ACCEPTABLE_FILE_SIZE_BYTES = - process.env.ACUANT_MINIMUM_FILE_SIZE === undefined - ? 250 * 1024 - : Number(process.env.ACUANT_MINIMUM_FILE_SIZE); +const ACCEPTABLE_SHARPNESS_SCORE = 50; /** * Returns an instance of File representing the given data URL. @@ -90,9 +95,6 @@ function AcuantCapture( onChange = () => {}, capture, className, - minimumGlareScore = DEFAULT_ACCEPTABLE_GLARE_SCORE, - minimumSharpnessScore = DEFAULT_ACCEPTABLE_SHARPNESS_SCORE, - minimumFileSize = DEFAULT_ACCEPTABLE_FILE_SIZE_BYTES, allowUpload = true, errorMessage, }, @@ -100,10 +102,12 @@ function AcuantCapture( ) { const fileCache = useContext(FileBase64CacheContext); const { isReady, isError, isCameraSupported } = useContext(AcuantContext); + const { isMockClient } = useContext(UploadContext); const inputRef = useRef(/** @type {?HTMLInputElement} */ (null)); const isForceUploading = useRef(false); - const [isCapturing, setIsCapturing] = useState(false); + const [isCapturingEnvironment, setIsCapturingEnvironment] = useState(false); const [ownErrorMessage, setOwnErrorMessage] = useState(/** @type {?string} */ (null)); + const ifStillMounted = useIfStillMounted(); useMemo(() => setOwnErrorMessage(null), [value]); const { isMobile } = useContext(DeviceContext); const { t, formatHTML } = useI18n(); @@ -113,11 +117,21 @@ function AcuantCapture( // capture is supported. This takes advantage of the fact that state setter is noop if value of // `isCapturing` is already false. if (!hasCapture) { - setIsCapturing(false); + setIsCapturingEnvironment(false); } }, [hasCapture]); useImperativeHandle(ref, () => inputRef.current); + /** + * Calls onChange with next value and resets any errors which may be present. + * + * @param {Blob?} nextValue Next value. + */ + function onChangeAndResetError(nextValue) { + setOwnErrorMessage(null); + onChange(nextValue); + } + /** * Responds to a click by starting capture if supported in the environment, or triggering the * default file picker prompt. The click event may originate from the file input itself, or @@ -127,14 +141,24 @@ function AcuantCapture( */ function startCaptureOrTriggerUpload(event) { if (event.target === inputRef.current) { - const shouldStartCapture = hasCapture && !capture && !isForceUploading.current; + const shouldStartEnvironmentCapture = + hasCapture && capture !== 'user' && !isForceUploading.current; + const shouldStartSelfieCapture = capture === 'user' && !isForceUploading.current; - if ((!allowUpload && !capture) || shouldStartCapture) { + if (!allowUpload || shouldStartSelfieCapture || shouldStartEnvironmentCapture) { event.preventDefault(); } - if (shouldStartCapture) { - setIsCapturing(true); + if (shouldStartSelfieCapture) { + /** @type {AcuantGlobal} */ (window).AcuantPassiveLiveness.startSelfieCapture( + ifStillMounted((nextImageData) => { + const dataAsBlob = toBlob(`data:image/jpeg;base64,${nextImageData}`); + fileCache.set(dataAsBlob, nextImageData); + onChangeAndResetError(dataAsBlob); + }), + ); + } else if (shouldStartEnvironmentCapture) { + setIsCapturingEnvironment(true); } isForceUploading.current = false; @@ -143,22 +167,6 @@ function AcuantCapture( } } - /** - * Calls onChange with next value if valid. Validation occurs separately to AcuantCaptureCanvas - * for common checks derived from file properties (file size, etc). If invalid, error state is - * assigned with appropriate error message. - * - * @param {Blob?} nextValue Next value candidate. - */ - function onChangeIfValid(nextValue) { - if (nextValue && nextValue.size < minimumFileSize) { - setOwnErrorMessage(t('errors.doc_auth.photo_file_size')); - } else { - setOwnErrorMessage(null); - onChange(nextValue); - } - } - /** * Triggers upload to occur, regardless of support for direct capture. This is necessary since the * default behavior for interacting with the file input is intercepted when capture is supported. @@ -186,25 +194,25 @@ function AcuantCapture( return (
- {isCapturing && !capture && ( - setIsCapturing(false)}> + {isCapturingEnvironment && ( + setIsCapturingEnvironment(false)}> { - if (nextCapture.glare < minimumGlareScore) { + if (nextCapture.glare < ACCEPTABLE_GLARE_SCORE) { setOwnErrorMessage(t('errors.doc_auth.photo_glare')); - } else if (nextCapture.sharpness < minimumSharpnessScore) { + } else if (nextCapture.sharpness < ACCEPTABLE_SHARPNESS_SCORE) { setOwnErrorMessage(t('errors.doc_auth.photo_blurry')); } else { const dataAsBlob = toBlob(nextCapture.image.data); fileCache.set(dataAsBlob, nextCapture.image.data); - onChangeIfValid(dataAsBlob); + onChangeAndResetError(dataAsBlob); } - setIsCapturing(false); + setIsCapturingEnvironment(false); }} onImageCaptureFailure={() => { setOwnErrorMessage(t('errors.doc_auth.capture_failure')); - setIsCapturing(false); + setIsCapturingEnvironment(false); }} /> @@ -214,12 +222,12 @@ function AcuantCapture( label={label} hint={hasCapture || !allowUpload ? undefined : t('doc_auth.tips.document_capture_hint')} bannerText={bannerText} - accept={['image/*']} + accept={isMockClient ? undefined : ['image/*']} capture={capture} value={value} errorMessage={ownErrorMessage ?? errorMessage} onClick={startCaptureOrTriggerUpload} - onChange={onChangeIfValid} + onChange={onChangeAndResetError} onError={() => setOwnErrorMessage(null)} />
diff --git a/app/javascript/packages/document-capture/components/file-input.jsx b/app/javascript/packages/document-capture/components/file-input.jsx index 2538d0127d0..5068ac2c529 100644 --- a/app/javascript/packages/document-capture/components/file-input.jsx +++ b/app/javascript/packages/document-capture/components/file-input.jsx @@ -172,11 +172,15 @@ const FileInput = forwardRef((props, ref) => { onDrop={() => setIsDraggingOver(false)} >
- {value && value instanceof window.File && !isMobile && ( + {value && value instanceof window.Blob && !isMobile && (
- {t('doc_auth.forms.selected_file')}: - {value.name}{' '} + {value instanceof window.File && ( + <> + {t('doc_auth.forms.selected_file')}: + {value.name}{' '} + + )} {t('doc_auth.forms.change_file')}
diff --git a/app/javascript/packages/document-capture/components/full-screen.jsx b/app/javascript/packages/document-capture/components/full-screen.jsx index 454bd45ad39..87d743c2fdb 100644 --- a/app/javascript/packages/document-capture/components/full-screen.jsx +++ b/app/javascript/packages/document-capture/components/full-screen.jsx @@ -1,5 +1,5 @@ -import React, { useRef, useEffect } from 'react'; -import createFocusTrap from 'focus-trap'; +import React, { useRef, useEffect, useCallback } from 'react'; +import { createFocusTrap } from 'focus-trap'; import useI18n from '../hooks/use-i18n'; import useAsset from '../hooks/use-asset'; @@ -26,7 +26,6 @@ let activeInstances = 0; function FullScreen({ onRequestClose = () => {}, children }) { const { t } = useI18n(); const { getAssetPath } = useAsset(); - const modalRef = useRef(/** @type {?HTMLDivElement} */ (null)); const trapRef = useRef(/** @type {?import('focus-trap').FocusTrap} */ (null)); const onRequestCloseRef = useRef(onRequestClose); useEffect(() => { @@ -35,13 +34,18 @@ function FullScreen({ onRequestClose = () => {}, children }) { // to reference in the deactivation. onRequestCloseRef.current = onRequestClose; }, [onRequestClose]); - useEffect(() => { - if (modalRef.current) { - trapRef.current = createFocusTrap(modalRef.current, { + + const setFocusTrapRef = useCallback((node) => { + if (trapRef.current) { + trapRef.current.deactivate(); + } + + if (node) { + trapRef.current = createFocusTrap(node, { onDeactivate: () => onRequestCloseRef.current(), }); + trapRef.current.activate(); - return trapRef.current.deactivate; } }, []); @@ -58,7 +62,7 @@ function FullScreen({ onRequestClose = () => {}, children }) { }, []); return ( -
+
+
+
    + <% Navigation.new(user: current_user).navigation_items.each do |nav_item| %> +
  • + <%= link_to nav_item.href, class: current_page?(nav_item.href) ? 'usa-current' : '' do %> + <%= nav_item.title %> + <% end %> + <% if nav_item.children&.any? %> +
      + <% nav_item.children.each do |child_nav_item| %> +
    • + <%= link_to child_nav_item.title, child_nav_item.href, class: current_page?(child_nav_item.href) ? 'usa-current' : '' %> +
    • + <% end %> +
    + <% end %> +
  • + <% end %> +
+
+ diff --git a/app/views/accounts/_nav_auth.html.erb b/app/views/accounts/_nav_auth.html.erb index cd1c4dc43f6..07368bb96f8 100644 --- a/app/views/accounts/_nav_auth.html.erb +++ b/app/views/accounts/_nav_auth.html.erb @@ -1,29 +1,32 @@ - +
diff --git a/app/views/accounts/_phone.html.erb b/app/views/accounts/_phone.html.erb index dc1c3e37e40..fbf564f219c 100644 --- a/app/views/accounts/_phone.html.erb +++ b/app/views/accounts/_phone.html.erb @@ -1,29 +1,32 @@ -
-
-
+
+
+

<%= t('account.index.phone') %> -

-
- + +
+ <% if EmailPolicy.new(current_user).can_add_email? %> + + <% end %>
- <% MfaContext.new(current_user).phone_configurations.each do |phone_configuration| %> -
-
- diff --git a/app/views/accounts/_pii.html.erb b/app/views/accounts/_pii.html.erb index 1bd791c3b4a..a8cf78d69fb 100644 --- a/app/views/accounts/_pii.html.erb +++ b/app/views/accounts/_pii.html.erb @@ -1,8 +1,8 @@ <% if locked_for_session %> -
-
-
-
+
+
+
+
<%= t('account.re_verify.banner') %> <%= link_to(t('account.re_verify.footer'), user_password_confirm_path) %> @@ -11,81 +11,64 @@
<% end %> -
-
-
- <%= t('headings.account.profile_info') %> - <%= image_tag asset_url('user.svg'), width: 8, class: 'ml1' %> -
-
- <%= image_tag asset_url('lock.svg'), width: 8, class: 'mr1' %> +
+
+
+

+ <%= t('headings.account.profile_info') %> + <%= image_tag asset_url('lock.svg'), width: 8 %> +

-
-
-
+
+
+
<%= t('account.index.full_name') %>
-
+
<%= pii.full_name %>
-
-
-
-
+
+
<%= t('account.index.address') %>
-
+
<%= render 'shared/address', address: pii %>
-
-
-
-
+
+
<%= t('account.index.dob') %>
-
+
<%= pii.dob %>
-
-
-
-
+
+
<%= t('account.index.ssn') %>
-
+
***-**-****
-
-
-
-
+
+
<%= t('account.index.phone') %>
-
+
<%= pii.phone %>
<% unless locked_for_session %> -
- <% if !pii.obfuscated %> -
- <%= image_tag asset_url('lock.svg'), width: 8, class: 'mr1' %> - <%= t('account.security.text') %> -
- <%= link_to t('account.security.link'), MarketingSite.help_url %> - <% else %> -
- <%= image_tag asset_url('lock.svg'), width: 8, class: 'mr1' %> - <%= t('account.security.text') %> - <%= link_to t('account.security.link'), MarketingSite.help_url %> -
- <% end %> +
+
+ <%= image_tag asset_url('lock.svg'), width: 8, class: 'mr1' %> + <%= t('account.security.text') %> +
+ <%= link_to t('account.security.link'), MarketingSite.help_url %>
<% end %>
diff --git a/app/views/accounts/_piv_cac.html.erb b/app/views/accounts/_piv_cac.html.erb index 97529a2ce7e..817cfd08f1a 100644 --- a/app/views/accounts/_piv_cac.html.erb +++ b/app/views/accounts/_piv_cac.html.erb @@ -1,10 +1,10 @@ -
-
-
- PIV CAC Cards -
+
+
+

+ <%= t('headings.account.federal_employee_id') %> +

<% if current_user.piv_cac_configurations.count < Figaro.env.max_piv_cac_per_account.to_i %> -
+
@@ -12,19 +12,21 @@ <% end %>
- <% MfaContext.new(current_user).piv_cac_configurations.each do |piv_cac_configuration| %> -
-
- diff --git a/app/views/accounts/_side_nav.html.erb b/app/views/accounts/_side_nav.html.erb new file mode 100644 index 00000000000..c0551e9d23b --- /dev/null +++ b/app/views/accounts/_side_nav.html.erb @@ -0,0 +1,20 @@ + diff --git a/app/views/accounts/_webauthn.html.erb b/app/views/accounts/_webauthn.html.erb index 1ee31a8887e..d2c434122f0 100644 --- a/app/views/accounts/_webauthn.html.erb +++ b/app/views/accounts/_webauthn.html.erb @@ -1,30 +1,33 @@ -
-
-
+
+
+

<%= t('account.index.webauthn') %> -

-
- -
-<% MfaContext.new(current_user).webauthn_configurations.each do |cfg| %> -