diff --git a/app/controllers/api/verify/base_controller.rb b/app/controllers/api/verify/base_controller.rb index 7374f0c5bca..64d8f526df8 100644 --- a/app/controllers/api/verify/base_controller.rb +++ b/app/controllers/api/verify/base_controller.rb @@ -2,19 +2,6 @@ module Api module Verify class BaseController < ApplicationController skip_before_action :verify_authenticity_token - include RenderConditionConcern - - class_attribute :required_step - - def self.required_step - NotImplementedError.new('Controller must define required_step') - end - - check_or_render_not_found -> do - required_step = self.class.required_step - raise required_step if required_step.is_a?(NotImplementedError) - !required_step || IdentityConfig.store.idv_api_enabled_steps.include?(required_step) - end before_action :confirm_two_factor_authenticated_for_api respond_to :json diff --git a/app/controllers/api/verify/document_capture_controller.rb b/app/controllers/api/verify/document_capture_controller.rb index 276b4b474e7..7804b299829 100644 --- a/app/controllers/api/verify/document_capture_controller.rb +++ b/app/controllers/api/verify/document_capture_controller.rb @@ -1,7 +1,6 @@ module Api module Verify class DocumentCaptureController < BaseController - self.required_step = nil include ApplicationHelper include EffectiveUser diff --git a/app/controllers/api/verify/document_capture_errors_controller.rb b/app/controllers/api/verify/document_capture_errors_controller.rb index 0a827f2f8e8..d454039ff66 100644 --- a/app/controllers/api/verify/document_capture_errors_controller.rb +++ b/app/controllers/api/verify/document_capture_errors_controller.rb @@ -3,8 +3,6 @@ module Verify class DocumentCaptureErrorsController < BaseController include EffectiveUser - self.required_step = nil - def delete form = DocumentCaptureErrorsDeleteForm.new( document_capture_session_uuid: params[:document_capture_session_uuid], diff --git a/app/controllers/api/verify/password_confirm_controller.rb b/app/controllers/api/verify/password_confirm_controller.rb deleted file mode 100644 index eb1c322fde1..00000000000 --- a/app/controllers/api/verify/password_confirm_controller.rb +++ /dev/null @@ -1,93 +0,0 @@ -module Api - module Verify - class PasswordConfirmController < BaseController - rescue_from UspsInPersonProofing::Exception::RequestEnrollException, - with: :handle_request_enroll_exception - - self.required_step = 'password_confirm' - - def create - result, personal_key = form.submit - - if result.success? - user = User.find_by(uuid: result.extra[:user_uuid]) - add_proofing_component(user) - store_session_last_gpo_code(form.gpo_code) - - render json: { - personal_key: personal_key, - completion_url: completion_url(result, user), - } - else - render_errors(result.errors) - end - end - - private - - def form - @form ||= Api::ProfileCreationForm.new( - password: verify_params[:password], - jwt: verify_params[:user_bundle_token], - user_session: user_session, - service_provider: current_sp, - ) - end - - def store_session_last_gpo_code(code) - session[:last_gpo_confirmation_code] = code if code && FeatureManagement.reveal_gpo_code? - end - - def verify_params - params.permit(:password, :user_bundle_token) - end - - def add_proofing_component(user) - ProofingComponent.create_or_find_by(user: user).update(verified_at: Time.zone.now) - end - - def completion_url(result, user) - if result.extra[:profile_pending] - idv_come_back_later_url - elsif in_person_enrollment?(user) - idv_in_person_ready_to_verify_url - elsif blocked_by_device_profiling?(user) - idv_setup_errors_url - elsif current_sp - sign_up_completed_url - else - account_url - end - end - - def in_person_enrollment?(user) - return false unless IdentityConfig.store.in_person_proofing_enabled - ProofingComponent.find_by(user: user)&.document_check == Idp::Constants::Vendors::USPS - end - - def blocked_by_device_profiling?(user) - return false unless IdentityConfig.store.proofing_device_profiling_decisioning_enabled - proofing_component = ProofingComponent.find_by(user: user) - # pass users who are inbetween feature flag being enabled and have not had a check run. - return false if proofing_component.threatmetrix_review_status.nil? - proofing_component.threatmetrix_review_status != 'pass' - end - - def handle_request_enroll_exception(err) - analytics.idv_in_person_usps_request_enroll_exception( - context: context, - enrollment_id: err.enrollment_id, - exception_class: err.class.to_s, - original_exception_class: err.exception_class, - exception_message: err.message, - reason: 'Request exception', - ) - render_errors( - { internal: [ - I18n.t('idv.failure.exceptions.internal_error'), - ] }, - ) - end - end - end -end diff --git a/app/controllers/api/verify/password_reset_controller.rb b/app/controllers/api/verify/password_reset_controller.rb deleted file mode 100644 index 2b4d6d69ca9..00000000000 --- a/app/controllers/api/verify/password_reset_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -module Api - module Verify - class PasswordResetController < BaseController - self.required_step = 'password_confirm' - - def create - analytics.idv_forgot_password_confirmed - request_id = sp_session[:request_id] - email = current_user.confirmed_email_addresses.first.email - reset_password(email, request_id) - - render json: { redirect_url: forgot_password_url(request_id: request_id) }, - status: :accepted - end - - private - - def reset_password(email, request_id) - sign_out - RequestPasswordReset.new( - email: email, - request_id: request_id, - analytics: analytics, - irs_attempts_api_tracker: irs_attempts_api_tracker, - ).perform - session[:email] = email - end - end - end -end diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 4aa54a3516b..c1559de514d 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -8,7 +8,6 @@ class ReviewController < ApplicationController before_action :confirm_idv_steps_complete before_action :confirm_idv_phone_confirmed - before_action :redirect_to_idv_app_if_enabled before_action :confirm_current_password, only: [:create] rescue_from UspsInPersonProofing::Exception::RequestEnrollException, @@ -72,11 +71,6 @@ def log_reproof_event irs_attempts_api_tracker.idv_reproof end - def redirect_to_idv_app_if_enabled - return if !IdentityConfig.store.idv_api_enabled_steps.include?('password_confirm') - redirect_to idv_app_path - end - def flash_message_content if idv_session.address_verification_mechanism != 'gpo' phone_of_record_msg = ActionController::Base.helpers.content_tag( diff --git a/app/controllers/verify_controller.rb b/app/controllers/verify_controller.rb deleted file mode 100644 index 290389fcd34..00000000000 --- a/app/controllers/verify_controller.rb +++ /dev/null @@ -1,67 +0,0 @@ -class VerifyController < ApplicationController - include RenderConditionConcern - include IdvSession - - check_or_render_not_found -> { FeatureManagement.idv_api_enabled? }, only: [:show] - - before_action :validate_step - before_action :confirm_two_factor_authenticated - before_action :confirm_idv_vendor_session_started - - def show - @app_data = app_data - end - - private - - def validate_step - render_not_found if params[:step].present? && !enabled_steps.include?(params[:step]) - end - - def app_data - user_session[:idv_api_store_key] ||= Base64.strict_encode64(random_encryption_key) - - { - base_path: idv_app_path, - cancel_url: idv_cancel_path, - initial_values: initial_values, - reset_password_url: forgot_password_url, - enabled_step_names: enabled_steps, - store_key: user_session[:idv_api_store_key], - } - end - - def initial_values - case first_step - when 'password_confirm' - { 'userBundleToken' => user_bundle_token } - end - end - - def first_step - enabled_steps.detect { |step| step_enabled?(step) } - end - - def enabled_steps - steps = IdentityConfig.store.idv_api_enabled_steps - - return steps if FeatureManagement.idv_personal_key_confirmation_enabled? - - steps - ['personal_key_confirm'] - end - - def step_enabled?(step) - enabled_steps.include?(step) - end - - def random_encryption_key - Encryption::AesCipher.encryption_cipher.random_key - end - - def user_bundle_token - Idv::UserBundleTokenizer.new( - user: current_user, - idv_session: idv_session, - ).token - end -end diff --git a/app/decorators/api/user_bundle_decorator.rb b/app/decorators/api/user_bundle_decorator.rb deleted file mode 100644 index c1aaaace8e5..00000000000 --- a/app/decorators/api/user_bundle_decorator.rb +++ /dev/null @@ -1,49 +0,0 @@ -module Api - class UserBundleError < StandardError; end - - class UserBundleDecorator - # Note, does not rescue JWT errors - responsibility of the user - def initialize(user_bundle:, public_key:) - payload, headers = JWT.decode( - user_bundle, - public_key, - true, - algorithm: 'RS256', - ) - @jwt_payload = payload - @jwt_headers = headers - - raise UserBundleError.new('pii is missing') unless jwt_payload['pii'] - raise UserBundleError.new('metadata is missing') unless jwt_payload['metadata'] - end - - def gpo_address_verification? - metadata[:address_verification_mechanism] == 'gpo' - end - - def pii - HashWithIndifferentAccess.new(jwt_payload['pii']) - end - - def user - return @user if defined?(@user) - @user = User.find_by(uuid: jwt_headers['sub']) - end - - def user_phone_confirmation? - metadata[:user_phone_confirmation] == true - end - - def vendor_phone_confirmation? - metadata[:vendor_phone_confirmation] == true - end - - private - - attr_reader :jwt_payload, :jwt_headers - - def metadata - HashWithIndifferentAccess.new(jwt_payload['metadata']) - end - end -end diff --git a/app/forms/api/profile_creation_form.rb b/app/forms/api/profile_creation_form.rb deleted file mode 100644 index cf95b6b8f5e..00000000000 --- a/app/forms/api/profile_creation_form.rb +++ /dev/null @@ -1,180 +0,0 @@ -module Api - class ProfileCreationForm - include ActiveModel::Model - - validate :valid_jwt - validate :valid_user - validate :valid_password - - attr_reader :password, :user_bundle, :user_session, :service_provider, :profile, :gpo_code - - def initialize(password:, jwt:, user_session:, service_provider: nil) - @password = password - @jwt = jwt - @user_session = user_session - @service_provider = service_provider - set_idv_session - end - - def submit - @form_valid = valid? - create_profile if form_valid? - - response = FormResponse.new( - success: form_valid?, - errors: errors, - extra: extra_attributes, - ) - [response, personal_key] - end - - private - - attr_reader :jwt - - def create_profile - profile_maker = build_profile_maker - profile = profile_maker.save_profile( - active: deactivation_reason.nil?, - deactivation_reason: deactivation_reason, - ) - @profile = profile - session[:pii] = profile_maker.pii_attributes - session[:profile_id] = profile.id - session[:personal_key] = profile.personal_key - - cache_encrypted_pii - associate_in_person_enrollment_with_profile - - if profile.active - move_pii_to_user_session - elsif user_bundle.gpo_address_verification? - create_gpo_entry - elsif in_person_enrollment? - UspsInPersonProofing::EnrollmentHelper.schedule_in_person_enrollment(user, session[:pii]) - end - end - - def deactivation_reason - if !phone_confirmed? || user_bundle.gpo_address_verification? - :gpo_verification_pending - elsif in_person_enrollment? - :in_person_verification_pending - elsif threatmetrix_failed_and_needs_review? - :threatmetrix_review_pending - end - end - - def cache_encrypted_pii - cacher = Pii::Cacher.new(user, session) - cacher.save(password, profile) - end - - def associate_in_person_enrollment_with_profile - return unless in_person_enrollment? && user.establishing_in_person_enrollment - user.establishing_in_person_enrollment.update(profile: profile) - end - - def phone_confirmed? - user_bundle.vendor_phone_confirmation? && user_bundle.user_phone_confirmation? - end - - def move_pii_to_user_session - return if session[:decrypted_pii].blank? - user_session[:decrypted_pii] = session.delete(:decrypted_pii) - end - - def create_gpo_entry - move_pii_to_user_session - confirmation_maker = GpoConfirmationMaker.new( - pii: Pii::Cacher.new(user, user_session).fetch, - service_provider: service_provider, - profile: profile, - ) - confirmation_maker.perform - @gpo_code = confirmation_maker.otp if FeatureManagement.reveal_gpo_code? - end - - def build_profile_maker - Idv::ProfileMaker.new( - applicant: user_bundle.pii, - user: user, - user_password: password, - ) - end - - def user - user_bundle&.user - end - - def set_idv_session - return if session.present? - user_session[:idv] = {} - end - - def session - user_session.fetch(:idv, {}) - end - - def valid_jwt - @user_bundle = Api::UserBundleDecorator.new(user_bundle: jwt, public_key: public_key) - rescue JWT::DecodeError - errors.add(:jwt, I18n.t('idv.failure.exceptions.internal_error'), type: :decode_error) - rescue ::Api::UserBundleError - errors.add(:jwt, I18n.t('idv.failure.exceptions.internal_error'), type: :user_bundle_error) - end - - def valid_user - return if user - errors.add(:user, I18n.t('devise.failure.unauthenticated'), type: :invalid_user) - end - - def valid_password - return if user&.valid_password?(password) - errors.add(:password, I18n.t('idv.errors.incorrect_password'), type: :invalid_password) - end - - def form_valid? - @form_valid - end - - def extra_attributes - if user.present? - @extra_attributes ||= { - profile_pending: user_bundle.gpo_address_verification?, - user_uuid: user.uuid, - } - else - @extra_attributes = {} - end - end - - def personal_key - @personal_key ||= profile&.personal_key || profile&.encrypt_recovery_pii(pii) - end - - def public_key - key = OpenSSL::PKey::RSA.new(Base64.strict_decode64(IdentityConfig.store.idv_public_key)) - - if Identity::Hostdata.in_datacenter? - env = Identity::Hostdata.env - prod_env = env == 'prod' || env == 'staging' || env == 'dm' - raise 'key size too small' if prod_env && key.n.num_bits < 2048 - end - - key - end - - def in_person_enrollment? - ProofingComponent.find_by(user: user)&.document_check == Idp::Constants::Vendors::USPS - end - - def threatmetrix_failed_and_needs_review? - return unless IdentityConfig.store.lexisnexis_threatmetrix_required_to_verify - return unless IdentityConfig.store.lexisnexis_threatmetrix_enabled - component = ProofingComponent.find_by(user: user) - return true unless component - !(component.threatmetrix && component.threatmetrix_review_status == 'pass') - end - end -end diff --git a/app/javascript/packages/clipboard-button/clipboard-button.spec.tsx b/app/javascript/packages/clipboard-button/clipboard-button.spec.tsx deleted file mode 100644 index 844195a379a..00000000000 --- a/app/javascript/packages/clipboard-button/clipboard-button.spec.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { render } from '@testing-library/react'; -import ClipboardButton from './clipboard-button'; -import ClipboardButtonElement from './clipboard-button-element'; - -describe('ClipboardButton', () => { - it('renders custom element with clipboard text data attribute', () => { - const clipboardText = 'foo'; - const { container } = render(); - - const element = container.firstElementChild as ClipboardButtonElement; - - expect(element.tagName).to.equal('LG-CLIPBOARD-BUTTON'); - expect(element.dataset.clipboardText).to.equal(clipboardText); - expect(element.textContent).to.equal('components.clipboard_button.label'); - }); - - it('forwards all other props to the button child', () => { - const { getByRole } = render(); - - const button = getByRole('button', { name: 'components.clipboard_button.label' }); - - expect(button.closest('lg-clipboard-button')).to.exist(); - expect(button.classList.contains('usa-button--outline')).to.be.true(); - }); - - it('renders with print icon', () => { - const { getByRole } = render(); - - const icon = getByRole('img', { hidden: true }); - - expect(icon.classList.contains('usa-icon')).to.be.true(); - expect(icon.querySelector('use[href$="#content_copy"]')); - }); -}); diff --git a/app/javascript/packages/clipboard-button/clipboard-button.tsx b/app/javascript/packages/clipboard-button/clipboard-button.tsx deleted file mode 100644 index a2dd5669a03..00000000000 --- a/app/javascript/packages/clipboard-button/clipboard-button.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { HTMLAttributes } from 'react'; -import { Button } from '@18f/identity-components'; -import type { ButtonProps } from '@18f/identity-components'; -import { t } from '@18f/identity-i18n'; -import type ClipboardButtonElement from './clipboard-button-element'; -import './clipboard-button-element'; - -declare global { - namespace JSX { - interface IntrinsicElements { - 'lg-clipboard-button': HTMLAttributes & { class?: string }; - } - } -} - -interface ClipboardButtonProps { - /** - * Text to be copied to the clipboard upon click. - */ - clipboardText: string; -} - -function ClipboardButton({ clipboardText, ...buttonProps }: ClipboardButtonProps & ButtonProps) { - return ( - - - - ); -} - -export default ClipboardButton; diff --git a/app/javascript/packages/clipboard-button/index.ts b/app/javascript/packages/clipboard-button/index.ts deleted file mode 100644 index 67087fd2a59..00000000000 --- a/app/javascript/packages/clipboard-button/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as ClipboardButton } from './clipboard-button'; diff --git a/app/javascript/packages/form-steps/history-link.spec.tsx b/app/javascript/packages/form-steps/history-link.spec.tsx deleted file mode 100644 index 257a6ce25bb..00000000000 --- a/app/javascript/packages/form-steps/history-link.spec.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { render, fireEvent, createEvent } from '@testing-library/react'; -import HistoryLink from './history-link'; - -describe('HistoryLink', () => { - it('renders a link to the intended step', () => { - const { getByRole } = render(); - - const link = getByRole('link'); - - expect(link.getAttribute('href')).to.equal('/base/step'); - }); - - it('renders a visual button to the intended step', () => { - const { getByRole } = render(); - - const link = getByRole('link'); - - expect(link.getAttribute('href')).to.equal('/base/step'); - expect(link.classList.contains('usa-button')).to.be.true(); - expect(link.classList.contains('usa-button--big')).to.be.true(); - }); - - it('intercepts navigation to route using client-side routing', () => { - const { getByRole } = render(); - - const beforeHash = window.location.hash; - const link = getByRole('link'); - - const didPreventDefault = !fireEvent.click(link); - - expect(didPreventDefault).to.be.true(); - expect(window.location.hash).to.not.equal(beforeHash); - expect(window.location.hash).to.equal('#step'); - }); - - it('does not intercept navigation when holding modifier key', () => { - const { getByRole } = render(); - - const beforeHash = window.location.hash; - const link = getByRole('link'); - - for (const mod of ['metaKey', 'shiftKey', 'ctrlKey', 'altKey']) { - const didPreventDefault = !fireEvent.click(link, { [mod]: true }); - - expect(didPreventDefault).to.be.false(); - expect(window.location.hash).to.equal(beforeHash); - } - }); - - it('does not intercept navigation when clicking with other than main button', () => { - const { getByRole } = render(); - - const beforeHash = window.location.hash; - const link = getByRole('link'); - - // Reference: https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button#value - for (const button of [1, 2, 3, 4]) { - const didPreventDefault = !fireEvent.click(link, { button }); - - expect(didPreventDefault).to.be.false(); - expect(window.location.hash).to.equal(beforeHash); - } - }); - - it('does not intercept navigation if event was already default-prevented', () => { - const { getByRole } = render(); - - const beforeHash = window.location.hash; - const link = getByRole('link'); - const event = createEvent('click', link); - event.preventDefault(); - - fireEvent(link, event); - - expect(window.location.hash).to.equal(beforeHash); - }); -}); diff --git a/app/javascript/packages/form-steps/history-link.tsx b/app/javascript/packages/form-steps/history-link.tsx deleted file mode 100644 index 8464eb87ae5..00000000000 --- a/app/javascript/packages/form-steps/history-link.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useCallback } from 'react'; -import type { MouseEventHandler } from 'react'; -import { Link, Button } from '@18f/identity-components'; -import type { LinkProps, ButtonProps } from '@18f/identity-components'; -import useHistoryParam, { getParamURL } from './use-history-param'; -import type { ParamValue } from './use-history-param'; - -type HistoryLinkProps = (Partial> | Partial) & { - /** - * When using path fragments for maintaining history, the base path to which the current step name - * is appended. - */ - basePath?: string; - - /** - * The step to which the link should navigate. - */ - step: ParamValue; - - /** - * Whether to render the link with the appearance of a button. - */ - isVisualButton?: boolean; -}; - -/** - * Renders a link to the given step. Enhances a Link or Button to perform client-side routing using - * useHistoryParam hook. - * - * @param props Props object. - * - * @return Link element. - */ -function HistoryLink({ basePath, step, isVisualButton = false, ...extraProps }: HistoryLinkProps) { - const [, setPath] = useHistoryParam(undefined, { basePath }); - const handleClick = useCallback>( - (event) => { - if ( - !event.defaultPrevented && - !event.metaKey && - !event.shiftKey && - !event.ctrlKey && - !event.altKey && - event.button === 0 - ) { - event.preventDefault(); - setPath(step); - } - }, - [basePath], - ); - - const href = getParamURL(step, { basePath }); - - if (isVisualButton) { - return - - ); -} - -export default PrintButton; diff --git a/app/javascript/packages/secret-session-storage/index.spec.ts b/app/javascript/packages/secret-session-storage/index.spec.ts deleted file mode 100644 index 94657aadcc8..00000000000 --- a/app/javascript/packages/secret-session-storage/index.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -import sinon from 'sinon'; -import SecretSessionStorage from './index'; - -describe('SecretSessionStorage', () => { - const STORAGE_KEY = 'test'; - - const sandbox = sinon.createSandbox(); - - let key: CryptoKey; - before(async () => { - key = await window.crypto.subtle.generateKey( - { - name: 'AES-GCM', - length: 256, - }, - true, - ['encrypt', 'decrypt'], - ); - }); - - function createStorage() { - const storage = new SecretSessionStorage(STORAGE_KEY); - storage.key = key; - return storage; - } - - afterEach(() => { - sessionStorage.removeItem(STORAGE_KEY); - sandbox.restore(); - }); - - it('silently ignores invalid written storage', async () => { - sessionStorage.setItem(STORAGE_KEY, 'nonsense'); - const storage = createStorage(); - await storage.load(); - }); - - describe('#setItem', () => { - it('writes to session storage', async () => { - sandbox.spy(Storage.prototype, 'setItem'); - - const storage = createStorage(); - await storage.setItem('foo', 'bar'); - - expect(Storage.prototype.setItem).to.have.been.calledWith( - STORAGE_KEY, - sinon.match( - (value: string) => - /^\[".+?",".+?"\]$/.test(value) && !value.includes('foo') && !value.includes('bar'), - ), - ); - }); - - it('can recall stored data', async () => { - const storage1 = createStorage(); - await storage1.setItem('foo', 'bar'); - const storage2 = createStorage(); - await storage2.load(); - - expect(storage2.getItem('foo')).to.equal('bar'); - }); - }); - - describe('#setItems', () => { - it('writes to session storage', async () => { - sandbox.spy(Storage.prototype, 'setItem'); - - const storage = createStorage(); - await storage.setItems({ foo: 'bar' }); - - expect(Storage.prototype.setItem).to.have.been.calledWith( - STORAGE_KEY, - sinon.match( - (value: string) => - /^\[".+?",".+?"\]$/.test(value) && !value.includes('foo') && !value.includes('bar'), - ), - ); - }); - - it('can recall stored data', async () => { - const storage1 = createStorage(); - await storage1.setItems({ foo: 'bar' }); - const storage2 = createStorage(); - await storage2.load(); - - expect(storage2.getItem('foo')).to.equal('bar'); - }); - }); - - describe('#getItem', () => { - it('returns the value from web storage', async () => { - const storage1 = createStorage(); - await storage1.setItem('foo', 'bar'); - - const storage2 = createStorage(); - await storage2.load(); - - expect(storage2.getItem('foo')).to.equal('bar'); - }); - - it('returns undefined for value not yet loaded from storage', async () => { - const storage1 = createStorage(); - await storage1.setItem('foo', 'bar'); - - const storage2 = createStorage(); - - expect(storage2.getItem('foo')).to.be.undefined(); - }); - - it('returns undefined for value not in loaded storage', async () => { - const storage1 = createStorage(); - await storage1.setItem('foo', 'bar'); - - const storage2 = createStorage(); - await storage2.load(); - - expect(storage2.getItem('baz')).to.be.undefined(); - }); - }); - - describe('#getItems', () => { - it('returns the values from web storage', async () => { - const storage1 = createStorage(); - await storage1.setItem('foo', 'bar'); - - const storage2 = createStorage(); - await storage2.load(); - - expect(storage2.getItems()).to.deep.equal({ foo: 'bar' }); - }); - - it('returns empty object for value not yet loaded from storage', async () => { - const storage1 = createStorage(); - await storage1.setItem('foo', 'bar'); - - const storage2 = createStorage(); - - expect(storage2.getItems()).to.deep.equal({}); - }); - }); - - describe('#clear', () => { - it('removes values from in-memory storage', async () => { - const storage = createStorage(); - await storage.setItem('foo', 'bar'); - storage.clear(); - - expect(storage.getItems()).to.deep.equal({}); - }); - - it('removes values from persisted storage', async () => { - const storage = createStorage(); - await storage.setItem('foo', 'bar'); - storage.clear(); - - expect(sessionStorage.getItem(STORAGE_KEY)).be.null(); - }); - }); -}); diff --git a/app/javascript/packages/secret-session-storage/index.ts b/app/javascript/packages/secret-session-storage/index.ts deleted file mode 100644 index 5c15e279d7d..00000000000 --- a/app/javascript/packages/secret-session-storage/index.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Serializable JSON value. - */ -type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }; - -/** - * Convert an ArrayBuffer to an equivalent string. - */ -export const ab2s = (buffer: ArrayBuffer) => - String.fromCharCode.apply(null, new Uint8Array(buffer)); - -/** - * Convert a string to an equivalent ArrayBuffer. - */ -export const s2ab = (string: string) => Uint8Array.from(string, (c) => c.charCodeAt(0)); - -class SecretSessionStorage> { - /** - * Web storage key. - */ - storageKey: string; - - /** - * In-memory reflection of unencrypted web storage payload. - */ - storage: S = {} as S; - - /** - * Encryption key. - */ - key: CryptoKey; - - /** - * Constructs a new session store. - * - * @param storageKey Web storage key. - * @param key Encryption key. - */ - constructor(storageKey: string) { - this.storageKey = storageKey; - } - - /** - * Reads and decrypts storage object into in-memory reflection, if available. - */ - async load() { - const storage = await this.#readStorage(); - if (storage) { - this.storage = storage; - } - } - - /** - * Sets a value into storage. - * - * @param key Storage object key. - * @param value Storage object value. - */ - async setItem(key: keyof S, value: S[typeof key]) { - this.storage[key] = value; - await this.#writeStorage(); - } - - /** - * Sets a patch of values into storage. - * - * @param values Storage object values. - */ - async setItems(values: Partial) { - Object.assign(this.storage, values); - await this.#writeStorage(); - } - - /** - * Gets a value from the in-memory storage. - * - * @param key Storage object key. - */ - getItem(key: keyof S) { - return this.storage[key]; - } - - /** - * Returns values from in-memory storage. - */ - getItems() { - return this.storage; - } - - /** - * Remove all values from in-memory and persisted storage. - */ - clear() { - sessionStorage.removeItem(this.storageKey); - this.storage = {} as S; - } - - /** - * Reads and decrypts storage object, if available. - */ - async #readStorage() { - try { - const storageData = sessionStorage.getItem(this.storageKey)!; - const [encryptedData, iv] = (JSON.parse(storageData) as [string, string]).map(s2ab); - const data = await window.crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - this.key, - encryptedData, - ); - - return JSON.parse(ab2s(data)); - } catch {} - } - - /** - * Encrypts and writes current in-memory reflection of storage object to web storage. - */ - async #writeStorage() { - const iv = window.crypto.getRandomValues(new Uint8Array(12)); - const encryptedData = await window.crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - this.key, - s2ab(JSON.stringify(this.storage)), - ); - - sessionStorage.setItem(this.storageKey, JSON.stringify([encryptedData, iv].map(ab2s))); - } -} - -export default SecretSessionStorage; diff --git a/app/javascript/packages/secret-session-storage/package.json b/app/javascript/packages/secret-session-storage/package.json deleted file mode 100644 index a57a3b1a085..00000000000 --- a/app/javascript/packages/secret-session-storage/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@18f/identity-secret-session-storage", - "private": true, - "version": "1.0.0" -} diff --git a/app/javascript/packages/verify-flow/README.md b/app/javascript/packages/verify-flow/README.md index 53116c3ce1c..218bcc55b8e 100644 --- a/app/javascript/packages/verify-flow/README.md +++ b/app/javascript/packages/verify-flow/README.md @@ -1,3 +1,3 @@ # `@18f/identity-verify-flow` -React front-end for the Login.gov IdP's identity verification flow. +React components for Login.gov's identity verification flow. diff --git a/app/javascript/packages/verify-flow/cancel.spec.tsx b/app/javascript/packages/verify-flow/cancel.spec.tsx index e8df4bc30fe..33492d2883e 100644 --- a/app/javascript/packages/verify-flow/cancel.spec.tsx +++ b/app/javascript/packages/verify-flow/cancel.spec.tsx @@ -16,8 +16,6 @@ describe('Cancel', () => { value={{ cancelURL: 'http://example.test/cancel', currentStep: 'one', - basePath: '', - onComplete() {}, }} > diff --git a/app/javascript/packages/verify-flow/context/address-verification-method-context.spec.tsx b/app/javascript/packages/verify-flow/context/address-verification-method-context.spec.tsx deleted file mode 100644 index a7de94b2d93..00000000000 --- a/app/javascript/packages/verify-flow/context/address-verification-method-context.spec.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useContext } from 'react'; -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import AddressVerificationMethodContext, { - AddressVerificationMethodContextProvider, -} from './address-verification-method-context'; - -describe('AddressVerificationMethodContextProvider', () => { - function TestComponent() { - const { addressVerificationMethod, setAddressVerificationMethod } = useContext( - AddressVerificationMethodContext, - ); - return ( - <> -
Current value: {String(addressVerificationMethod)}
- - - ); - } - - it('initializes with default value', () => { - const { getByText } = render( - - - , - ); - - expect(getByText('Current value: null')).to.exist(); - }); - - it('can be overridden with an initial method', () => { - const { getByText } = render( - - - , - ); - - expect(getByText('Current value: gpo')).to.exist(); - }); - - it('exposes a setter to change the value', async () => { - const { getByText } = render( - - - , - ); - - await userEvent.click(getByText('Update')); - - expect(getByText('Current value: phone')).to.exist(); - }); -}); diff --git a/app/javascript/packages/verify-flow/context/address-verification-method-context.tsx b/app/javascript/packages/verify-flow/context/address-verification-method-context.tsx deleted file mode 100644 index 254150cb248..00000000000 --- a/app/javascript/packages/verify-flow/context/address-verification-method-context.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { createContext, useState } from 'react'; -import { useObjectMemo } from '@18f/identity-react-hooks'; -import type { ReactNode } from 'react'; - -/** - * Mechanisms by which a user can verify their address. - */ -export type AddressVerificationMethod = 'phone' | 'gpo' | null; - -/** - * Context provider props. - */ -interface AddressVerificationMethodContextProviderProps { - /** - * Optional initial context value. - */ - initialMethod?: AddressVerificationMethod; - - /** - * Context children. - */ - children?: ReactNode; -} - -/** - * Context value. - */ -interface AddressVerificationMethodContextValue { - /** - * Current address verification method. - */ - addressVerificationMethod: AddressVerificationMethod; - - /** - * Setter to update to a new address verification method. - */ - setAddressVerificationMethod: (nextMethod: AddressVerificationMethod) => void; -} - -/** - * Default address verification method. - */ -const DEFAULT_METHOD: AddressVerificationMethod = null; - -/** - * Address verification method context container. - */ -const AddressVerificationMethodContext = createContext({ - addressVerificationMethod: DEFAULT_METHOD, - setAddressVerificationMethod: () => {}, -}); - -AddressVerificationMethodContext.displayName = 'AddressVerificationMethodContext'; - -export function AddressVerificationMethodContextProvider({ - initialMethod = DEFAULT_METHOD, - children, -}: AddressVerificationMethodContextProviderProps) { - const [addressVerificationMethod, setAddressVerificationMethod] = useState(initialMethod); - const value = useObjectMemo({ addressVerificationMethod, setAddressVerificationMethod }); - - return ( - - {children} - - ); -} - -export default AddressVerificationMethodContext; diff --git a/app/javascript/packages/verify-flow/context/flow-context.tsx b/app/javascript/packages/verify-flow/context/flow-context.tsx index 17d24ecbd8c..ea84e6ef6b1 100644 --- a/app/javascript/packages/verify-flow/context/flow-context.tsx +++ b/app/javascript/packages/verify-flow/context/flow-context.tsx @@ -15,23 +15,11 @@ export interface FlowContextValue { * Current step name. */ currentStep: string; - - /** - * The path to which the current step is appended to create the current step URL. - */ - basePath: string; - - /** - * Handle flow completion with a given destination URL. - */ - onComplete: ({ completionURL }: { completionURL: string }) => void; } const FlowContext = createContext({ cancelURL: '', currentStep: '', - basePath: '', - onComplete() {}, }); FlowContext.displayName = 'FlowContext'; diff --git a/app/javascript/packages/verify-flow/context/secrets-context.tsx b/app/javascript/packages/verify-flow/context/secrets-context.tsx deleted file mode 100644 index c5fa6dcd234..00000000000 --- a/app/javascript/packages/verify-flow/context/secrets-context.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { createContext, useContext, useEffect, useState } from 'react'; -import type { ReactNode, Dispatch } from 'react'; -import SecretSessionStorage from '@18f/identity-secret-session-storage'; -import type { VerifyFlowValues } from '../verify-flow'; - -type SecretKeys = 'userBundleToken' | 'personalKey' | 'completionURL'; - -export type SecretValues = Pick; - -type SetItems = typeof SecretSessionStorage.prototype.setItems; - -interface SecretsContextProviderProps { - /** - * Secrets storage. - */ - storage: SecretSessionStorage; - - /** - * Context provider children. - */ - children?: ReactNode; -} - -/** - * Minimal set of flow values to be synced to secret session storage. - */ -const SYNCED_SECRET_VALUES: SecretKeys[] = ['userBundleToken', 'personalKey', 'completionURL']; - -const SecretsContext = createContext({ - storage: new SecretSessionStorage(''), - setItems: (async () => {}) as SetItems, -}); - -SecretsContext.displayName = 'SecretsContext'; - -const pick = (obj: object, keys: string[]) => - Object.fromEntries(keys.map((key) => [key, obj[key]])); - -const isStorageEqual = (values: object, nextValues: object) => - Object.keys(nextValues).every((key) => values[key] === nextValues[key]); - -function useIdleCallbackEffect(callback: () => void, deps: any[]) { - useEffect(() => { - // Idle callback is implemented as a progressive enhancement in supported environments... - if (typeof requestIdleCallback === 'function') { - const callbackId = requestIdleCallback(callback); - return () => cancelIdleCallback(callbackId); - } - - // ...where the fallback behavior is to invoke the callback synchronously. - callback(); - }, deps); -} - -export function SecretsContextProvider({ storage, children }: SecretsContextProviderProps) { - const [value, setValue] = useState({ - storage, - async setItems(nextValues: SecretValues) { - await storage.setItems(nextValues); - setValue({ ...value }); - }, - }); - - return {children}; -} - -export function useSyncedSecretValues( - initialValues?: SecretValues, -): [SecretValues, Dispatch] { - const { storage, setItems } = useContext(SecretsContext); - const [values, setValues] = useState({ ...storage.getItems(), ...initialValues }); - - useIdleCallbackEffect(() => { - const nextSecretValues: SecretValues = pick(values, SYNCED_SECRET_VALUES); - if (!isStorageEqual(storage.getItems(), nextSecretValues)) { - setItems(nextSecretValues); - } - }, [values]); - - return [values, setValues]; -} - -export default SecretsContext; diff --git a/app/javascript/packages/verify-flow/error-boundary.tsx b/app/javascript/packages/verify-flow/error-boundary.tsx deleted file mode 100644 index 1fc04dd6de1..00000000000 --- a/app/javascript/packages/verify-flow/error-boundary.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Component } from 'react'; -import type { ReactNode } from 'react'; -import { trackError } from '@18f/identity-analytics'; -import ErrorStatusPage from './error-status-page'; - -interface ErrorBoundaryProps { - children: ReactNode; -} - -interface ErrorBoundaryState { - hasError: boolean; -} - -class ErrorBoundary extends Component { - constructor(props) { - super(props); - - this.state = { hasError: false }; - } - - static getDerivedStateFromError = () => ({ hasError: true }); - - componentDidCatch(error: Error) { - trackError(error); - } - - render() { - const { children } = this.props; - const { hasError } = this.state; - - return hasError ? : children; - } -} - -export default ErrorBoundary; diff --git a/app/javascript/packages/verify-flow/error-status-page.tsx b/app/javascript/packages/verify-flow/error-status-page.tsx deleted file mode 100644 index 870e0efc51a..00000000000 --- a/app/javascript/packages/verify-flow/error-status-page.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Button, Link, StatusPage } from '@18f/identity-components'; -import { t } from '@18f/identity-i18n'; -import { formatHTML } from '@18f/identity-react-i18n'; -import VerifyFlowTroubleshootingOptions from './verify-flow-troubleshooting-options'; - -function ErrorStatusPage() { - return ( - - {t('idv.failure.button.warning')} - , - ]} - troubleshootingOptions={} - > -

- {formatHTML(t('idv.failure.exceptions.text_html', { link: `` }), { - a() { - return ( - - {t('idv.failure.exceptions.link')} - - ); - }, - })} -

-
- ); -} - -export default ErrorStatusPage; diff --git a/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.spec.ts b/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.spec.ts deleted file mode 100644 index 29bf0f2f0d1..00000000000 --- a/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import sinon from 'sinon'; -import { renderHook } from '@testing-library/react-hooks'; -import { useDefineProperty } from '@18f/identity-test-helpers'; -import { FormStep } from '@18f/identity-form-steps'; -import useInitialStepValidation from './use-initial-step-validation'; - -const TEST_BASE_PATH = '/step/'; -const STEPS = [{ name: 'one' }, { name: 'two' }, { name: 'three' }] as FormStep[]; - -describe('useInitialStepValidation', () => { - const defineProperty = useDefineProperty(); - - context('with no path param', () => { - beforeEach(() => { - defineProperty(window, 'location', { - value: { - pathname: TEST_BASE_PATH, - }, - }); - }); - - it('returns the first step', () => { - const { result } = renderHook(() => useInitialStepValidation(TEST_BASE_PATH, STEPS)); - const [initialStep] = result.current; - - expect(initialStep).to.equal(STEPS[0].name); - }); - }); - - context('with path param exceeding progress', () => { - beforeEach(() => { - defineProperty(window, 'location', { - value: { - pathname: TEST_BASE_PATH + STEPS[1].name, - }, - }); - }); - - it('returns furthest step progress', () => { - const { result } = renderHook(() => useInitialStepValidation(TEST_BASE_PATH, STEPS)); - const [initialStep] = result.current; - - expect(initialStep).to.equal(STEPS[0].name); - }); - }); - - context('with path param not exceeding progress', () => { - beforeEach(() => { - defineProperty(window, 'location', { - value: { - pathname: TEST_BASE_PATH + STEPS[1].name, - }, - }); - - defineProperty(global, 'sessionStorage', { - value: { - getItem: sinon.stub().withArgs('completedStep').returns(STEPS[0].name), - }, - }); - }); - - it('returns path param', () => { - const { result } = renderHook(() => useInitialStepValidation(TEST_BASE_PATH, STEPS)); - const [initialStep] = result.current; - - expect(initialStep).to.equal(STEPS[1].name); - }); - }); -}); diff --git a/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.ts b/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.ts deleted file mode 100644 index d11bd2fb928..00000000000 --- a/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useMemo } from 'react'; -import type { Dispatch } from 'react'; -import { FormStep, getStepParam } from '@18f/identity-form-steps'; -import useSessionStorage from './use-session-storage'; - -/** - * Returns the index of the given step name in the form steps order. - * - * @param stepName Step name. - * @param steps Steps order. - * - * @return Step index. - */ -const getStepIndex = (stepName: string, steps: FormStep[]) => - steps.findIndex((step) => step.name === stepName); - -/** - * React hook which validates the expected initial step to present the user, based on past - * completion and presence of a URL path fragment. Behaves similar to a useState hook, where the - * return value is a tuple of the validated initial step, and a setter for assigning a completed - * step. - * - * @param basePath Path to which the current step is appended to create the current step URL. - * @param steps Steps order. - * - * @return Tuple of the validated initial step and a setter for assigning a completed step. - */ -function useInitialStepValidation( - basePath: string, - steps: FormStep[], -): [string, Dispatch] { - const [completedStep, setCompletedStep] = useSessionStorage('completedStep'); - const initialStep = useMemo(() => { - const pathStep = getStepParam(window.location.pathname.split(basePath)[1]); - const completedStepIndex = completedStep ? getStepIndex(completedStep, steps) : -1; - const pathStepIndex = getStepIndex(pathStep, steps); - const firstStepIndex = 0; - const stepIndex = Math.max(Math.min(completedStepIndex + 1, pathStepIndex), firstStepIndex); - return steps[stepIndex].name; - }, []); - - return [initialStep, setCompletedStep]; -} - -export default useInitialStepValidation; diff --git a/app/javascript/packages/verify-flow/hooks/use-session-storage.spec.ts b/app/javascript/packages/verify-flow/hooks/use-session-storage.spec.ts deleted file mode 100644 index 0a9800cb863..00000000000 --- a/app/javascript/packages/verify-flow/hooks/use-session-storage.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import sinon from 'sinon'; -import type { SinonStub } from 'sinon'; -import { act, renderHook } from '@testing-library/react-hooks'; -import { useDefineProperty } from '@18f/identity-test-helpers'; -import useSessionStorage from './use-session-storage'; - -const TEST_KEY = 'key'; - -describe('useSessionStorage', () => { - const defineProperty = useDefineProperty(); - - beforeEach(() => { - defineProperty(global, 'sessionStorage', { - value: { - getItem: sinon.stub(), - setItem: sinon.stub(), - removeItem: sinon.stub(), - }, - }); - }); - - afterEach(() => { - sessionStorage.removeItem(TEST_KEY); - }); - - it('returns null for a value not in storage', () => { - (sessionStorage.getItem as SinonStub).withArgs(TEST_KEY).returns(null); - - const { result } = renderHook(() => useSessionStorage(TEST_KEY)); - const [value, setValue] = result.current; - - expect(value).to.be.null(); - expect(setValue).to.be.a('function'); - expect(sessionStorage.setItem).not.to.have.been.called(); - }); - - it('returns a value from storage', () => { - (sessionStorage.getItem as SinonStub).withArgs(TEST_KEY).returns('value'); - - const { result } = renderHook(() => useSessionStorage(TEST_KEY)); - const [value, setValue] = result.current; - - expect(value).to.equal('value'); - expect(setValue).to.be.a('function'); - expect(sessionStorage.setItem).not.to.have.been.called(); - }); - - it('sets a string value into storage', () => { - const { result } = renderHook(() => useSessionStorage(TEST_KEY)); - const [, setValue] = result.current; - act(() => setValue('value')); - - expect(sessionStorage.setItem).to.have.been.calledWith(TEST_KEY, 'value'); - }); - - it('unsets storage when given a null value', () => { - const { result } = renderHook(() => useSessionStorage(TEST_KEY)); - const [, setValue] = result.current; - act(() => setValue(null)); - - expect(sessionStorage.removeItem).to.have.been.calledWith(TEST_KEY); - }); -}); diff --git a/app/javascript/packages/verify-flow/hooks/use-session-storage.ts b/app/javascript/packages/verify-flow/hooks/use-session-storage.ts deleted file mode 100644 index 803ab3ed09d..00000000000 --- a/app/javascript/packages/verify-flow/hooks/use-session-storage.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useState } from 'react'; -import { useDidUpdateEffect } from '@18f/identity-react-hooks'; -import type { Dispatch } from 'react'; - -/** - * React hook for maintaining a value in sessionStorage. - * - * @param key Session storage key. - * - * @return Tuple of the current value and a setter to assign a value into storage. - */ -function useSessionStorage(key: string): [string | null, Dispatch] { - const [value, setValue] = useState(() => sessionStorage.getItem(key)); - useDidUpdateEffect(() => { - if (value === null) { - sessionStorage.removeItem(key); - } else { - sessionStorage.setItem(key, value); - } - }, [value]); - - return [value, setValue]; -} - -export default useSessionStorage; diff --git a/app/javascript/packages/verify-flow/index.ts b/app/javascript/packages/verify-flow/index.ts index 38708f4881f..f9ddca4c3ac 100644 --- a/app/javascript/packages/verify-flow/index.ts +++ b/app/javascript/packages/verify-flow/index.ts @@ -1,12 +1,5 @@ -export { decodeUserBundle } from './user-bundle'; -export { default as ErrorStatusPage } from './error-status-page'; export { default as FlowContext } from './context/flow-context'; -export { SecretsContextProvider } from './context/secrets-context'; export { default as Cancel } from './cancel'; -export { default as VerifyFlow } from './verify-flow'; export { default as VerifyFlowStepIndicator, VerifyFlowPath } from './verify-flow-step-indicator'; export type { FlowContextValue } from './context/flow-context'; -export type { SecretValues } from './context/secrets-context'; -export type { AddressVerificationMethod } from './context/address-verification-method-context'; -export type { VerifyFlowValues } from './verify-flow'; diff --git a/app/javascript/packages/verify-flow/package.json b/app/javascript/packages/verify-flow/package.json index 3c471db9a0a..f059daef1dc 100644 --- a/app/javascript/packages/verify-flow/package.json +++ b/app/javascript/packages/verify-flow/package.json @@ -3,8 +3,6 @@ "version": "1.0.0", "private": true, "dependencies": { - "cleave.js": "^1.6.0", - "libphonenumber-js": "^1.10.11", "react": "^17.0.2" } } diff --git a/app/javascript/packages/verify-flow/services/api.spec.ts b/app/javascript/packages/verify-flow/services/api.spec.ts deleted file mode 100644 index 524412a3ce6..00000000000 --- a/app/javascript/packages/verify-flow/services/api.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { SinonStub } from 'sinon'; -import { useSandbox } from '@18f/identity-test-helpers'; -import { isErrorResponse, post } from './api'; - -describe('post', () => { - const sandbox = useSandbox(); - - beforeEach(() => { - sandbox.stub(window, 'fetch'); - }); - - it('sends to API route associated with current path', () => { - post('/foo/bar', 'body'); - - expect(window.fetch).to.have.been.calledWith( - 'http://example.test/foo/bar?locale=en', - sandbox.match({ method: 'POST', body: 'body' }), - ); - }); - - it('resolves to plaintext', async () => { - (window.fetch as SinonStub).resolves({ - text: () => Promise.resolve('response'), - } as Response); - - const response = await post('/foo/bar', 'body'); - - expect(response).to.equal('response'); - }); - - context('with json option', () => { - it('sends as JSON', () => { - post('/foo/bar', { foo: 'bar' }, { json: true }); - - expect(window.fetch).to.have.been.calledWith( - 'http://example.test/foo/bar?locale=en', - sandbox.match({ - method: 'POST', - body: '{"foo":"bar"}', - headers: { 'Content-Type': 'application/json' }, - }), - ); - }); - - it('resolves to parsed response JSON', async () => { - (window.fetch as SinonStub).resolves({ - json: () => Promise.resolve({ received: true }), - } as Response); - - const { received } = await post('/foo/bar', { foo: 'bar' }, { json: true }); - - expect(received).to.equal(true); - }); - }); - - context('with csrf option', () => { - it('sends CSRF', () => { - const csrf = document.createElement('meta'); - csrf.name = 'csrf-token'; - csrf.content = 'csrf-value'; - document.body.appendChild(csrf); - - post('/foo/bar', 'body', { csrf: true }); - - expect(window.fetch).to.have.been.calledWith( - 'http://example.test/foo/bar?locale=en', - sandbox.match({ - method: 'POST', - body: 'body', - headers: { 'X-CSRF-Token': 'csrf-value' }, - }), - ); - }); - }); -}); - -describe('isErrorResponse', () => { - it('returns false if object is not an error response', () => { - const response = {}; - const result = isErrorResponse(response); - - expect(result).to.be.false(); - }); - - it('returns true if object is an error response', () => { - const response = { errors: { field: ['message'] } }; - const result = isErrorResponse(response); - - expect(result).to.be.true(); - }); -}); diff --git a/app/javascript/packages/verify-flow/services/api.ts b/app/javascript/packages/verify-flow/services/api.ts deleted file mode 100644 index e44dedf784c..00000000000 --- a/app/javascript/packages/verify-flow/services/api.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { addSearchParams } from '@18f/identity-url'; - -export interface ErrorResponse { - errors: Record; -} - -interface PostOptions { - /** - * Whether to send the request as a JSON request. - */ - json: boolean; - - /** - * Whether to include CSRF token in the request. - */ - csrf: boolean; -} - -/** - * Submits the given payload to the API route controller associated with the current path, resolving - * to a promise containing the parsed response JSON object. - * - * @param body Request body. - * - * @return Parsed response JSON object. - */ -export async function post( - url: string, - body: BodyInit | object, - options: Partial = {}, -): Promise { - const { lang: locale } = document.documentElement; - const localizedURL = addSearchParams(url, { locale }); - - const headers: HeadersInit = {}; - - if (options.csrf) { - const csrf = document.querySelector('meta[name="csrf-token"]')?.content; - if (csrf) { - headers['X-CSRF-Token'] = csrf; - } - } - - if (options.json) { - headers['Content-Type'] = 'application/json'; - body = JSON.stringify(body); - } - - const response = await window.fetch(localizedURL, { - method: 'POST', - headers, - body: body as BodyInit, - }); - - return options.json ? response.json() : response.text(); -} - -export const isErrorResponse = ( - response: object | ErrorResponse, -): response is ErrorResponse => 'errors' in response; diff --git a/app/javascript/packages/verify-flow/steps/index.ts b/app/javascript/packages/verify-flow/steps/index.ts deleted file mode 100644 index fde4b7813e6..00000000000 --- a/app/javascript/packages/verify-flow/steps/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import personalKeyStep from './personal-key'; -import personalKeyConfirmStep from './personal-key-confirm'; -import passwordConfirmStep from './password-confirm'; - -export const STEPS = [passwordConfirmStep, personalKeyStep, personalKeyConfirmStep]; diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/forgot-password.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/forgot-password.tsx deleted file mode 100644 index 043aa6d1e03..00000000000 --- a/app/javascript/packages/verify-flow/steps/password-confirm/forgot-password.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { StatusPage } from '@18f/identity-components'; -import { t } from '@18f/identity-i18n'; -import { HistoryLink } from '@18f/identity-form-steps'; -import PasswordResetButton from './password-reset-button'; - -interface ForgotPasswordProps { - stepPath: string; -} - -export function ForgotPassword({ stepPath }: ForgotPasswordProps) { - return ( - - {t('idv.forgot_password.try_again')} -
, - , - ]} - > -
    - {t(['idv.forgot_password.warnings']).map((warning) => ( -
  • {warning}
  • - ))} -
- - ); -} diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/index.ts b/app/javascript/packages/verify-flow/steps/password-confirm/index.ts deleted file mode 100644 index b06eba50311..00000000000 --- a/app/javascript/packages/verify-flow/steps/password-confirm/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { t } from '@18f/identity-i18n'; -import { getConfigValue } from '@18f/identity-config'; -import type { FormStep } from '@18f/identity-form-steps'; -import type { VerifyFlowValues } from '../../verify-flow'; -import form from './password-confirm-step'; -import submit from './submit'; - -export default { - name: 'password_confirm', - title: t('idv.titles.session.review', { app_name: getConfigValue('appName') }), - form, - submit, -} as FormStep; diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.spec.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.spec.tsx deleted file mode 100644 index 57d5d935949..00000000000 --- a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.spec.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { computeAccessibleDescription } from 'dom-accessibility-api'; -import { accordion } from 'identity-style-guide'; -import type { SinonSpy } from 'sinon'; -import * as analytics from '@18f/identity-analytics'; -import { useSandbox, usePropertyValue } from '@18f/identity-test-helpers'; -import { FormSteps } from '@18f/identity-form-steps'; -import { t, i18n } from '@18f/identity-i18n'; -import PasswordConfirmStep from './password-confirm-step'; -import submit, { PasswordSubmitError } from './submit'; -import { AddressVerificationMethodContextProvider } from '../../context/address-verification-method-context'; - -describe('PasswordConfirmStep', () => { - const sandbox = useSandbox(); - const DEFAULT_PROPS = { - onChange() {}, - onError() {}, - errors: [], - toPreviousStep() {}, - registerField: () => () => {}, - unknownFieldErrors: [], - value: {}, - }; - - before(() => { - accordion.on(); - }); - - beforeEach(() => { - sandbox.spy(analytics, 'trackEvent'); - }); - - after(() => { - accordion.off(); - }); - - it('has a collapsed accordion by default', () => { - const { getByText } = render(); - - const button = getByText(t('idv.messages.review.intro')); - expect(button.getAttribute('aria-expanded')).to.eq('false'); - }); - - it('expands accordion when the accordion is clicked on', async () => { - const { getByText } = render(); - - const button = getByText(t('idv.messages.review.intro')); - await userEvent.click(button); - expect(button.getAttribute('aria-expanded')).to.eq('true'); - }); - - it('displays user information when the accordion is clicked on', async () => { - const { getByText } = render(); - - const button = getByText(t('idv.messages.review.intro')); - await userEvent.click(button); - - expect(getByText('idv.review.full_name')).to.exist(); - expect(getByText('idv.review.mailing_address')).to.exist(); - }); - - it('validates missing password', async () => { - const { getByRole, getByLabelText, queryByRole } = render( - , - ); - - await userEvent.click(getByRole('button', { name: 'forms.buttons.continue' })); - - // There should not be a top-level error alert, only field-specific. - expect(queryByRole('alert')).to.not.exist(); - const input = getByLabelText('components.password_toggle.label'); - const description = computeAccessibleDescription(input); - expect(description).to.equal('simple_form.required.text'); - }); - - it('validates incorrect password', async () => { - sandbox.stub(window, 'fetch').resolves({ - status: 400, - json: () => Promise.resolve({ errors: { password: ['Incorrect password'] } }), - } as Response); - - const { getByRole, findByRole, getByLabelText } = render( - , - ); - - sandbox.spy(Element.prototype, 'scrollIntoView'); - const continueButton = getByRole('button', { name: 'forms.buttons.continue' }); - - await userEvent.type(getByLabelText('components.password_toggle.label'), 'password'); - await userEvent.click(continueButton); - - // There should not be a field-specific error, only a top-level alert. - const alert = await findByRole('alert'); - expect(Element.prototype.scrollIntoView).to.have.been.calledOnce(); - const { thisValue: scrollElement } = (Element.prototype.scrollIntoView as SinonSpy).getCall(0); - expect((scrollElement as Element).contains(alert)).to.be.true(); - expect(alert.textContent).to.equal('Incorrect password'); - const input = getByLabelText('components.password_toggle.label'); - const description = computeAccessibleDescription(input); - expect(description).to.be.empty(); - - await userEvent.click(continueButton); - expect(Element.prototype.scrollIntoView).to.have.been.calledTwice(); - }); - - describe('forgot password', () => { - usePropertyValue(i18n, 'strings', { - 'idv.forgot_password.link_html': 'Forgot password? %{link}', - 'idv.forgot_password.warnings': [], - }); - - it('navigates to forgot password subpage', async () => { - const { getByText } = render(); - - await userEvent.click(getByText('idv.forgot_password.link_text')); - - expect(window.location.pathname).to.equal('/password_confirm/forgot_password'); - expect(analytics.trackEvent).to.have.been.calledWith('IdV: forgot password visited'); - expect(analytics.trackEvent).not.to.have.been.calledWith('IdV: password confirm visited'); - }); - - it('navigates back from forgot password subpage', async () => { - const { getByRole, getByText } = render(); - - await userEvent.click(getByText('idv.forgot_password.link_text')); - await userEvent.click(getByRole('link', { name: 'idv.forgot_password.try_again' })); - - expect(window.location.pathname).to.equal('/password_confirm'); - expect(analytics.trackEvent).to.have.been.calledWith('IdV: forgot password visited'); - expect(analytics.trackEvent).to.have.been.calledWith('IdV: password confirm visited'); - }); - }); - - describe('alert', () => { - context('with gpo as address verification method', () => { - it('does not render success alert', () => { - const { queryByRole } = render( - - - , - ); - - expect(queryByRole('status')).to.not.exist(); - }); - }); - - context('with phone as address verification method', () => { - it('renders success alert', () => { - const { queryByRole } = render( - - - , - ); - - const status = queryByRole('status')!; - - expect(status).to.exist(); - expect(status.textContent).to.equal('idv.messages.review.info_verified_html'); - }); - }); - - context('with errors', () => { - it('renders error messages', () => { - const { queryByRole } = render( - , - ); - - const alert = queryByRole('alert')!; - - expect(alert).to.exist(); - expect(alert.textContent).to.equal('Submit error'); - }); - }); - }); -}); diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx deleted file mode 100644 index d1edc50402e..00000000000 --- a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useContext } from 'react'; -import type { ChangeEvent } from 'react'; -import { useDidUpdateEffect } from '@18f/identity-react-hooks'; -import { t } from '@18f/identity-i18n'; -import { - FormStepsButton, - useHistoryParam, - FormStepsContext, - HistoryLink, -} from '@18f/identity-form-steps'; -import { PasswordToggle } from '@18f/identity-password-toggle'; -import { FlowContext } from '@18f/identity-verify-flow'; -import { formatHTML } from '@18f/identity-react-i18n'; -import { PageHeading, Accordion, Alert, Link, ScrollIntoView } from '@18f/identity-components'; -import { getConfigValue } from '@18f/identity-config'; -import { trackEvent } from '@18f/identity-analytics'; -import type { FormStepComponentProps } from '@18f/identity-form-steps'; -import { ForgotPassword } from './forgot-password'; -import PersonalInfoSummary from './personal-info-summary'; -import Cancel from '../../cancel'; -import AddressVerificationMethodContext from '../../context/address-verification-method-context'; -import type { VerifyFlowValues } from '../..'; -import { PasswordSubmitError } from './submit'; - -interface PasswordConfirmStepProps extends FormStepComponentProps {} - -const FORGOT_PASSWORD_PATH = 'forgot_password'; - -function useSubpageEventLogger(path) { - useDidUpdateEffect(() => { - switch (path) { - case 'forgot_password': - trackEvent('IdV: forgot password visited'); - break; - default: - trackEvent('IdV: password confirm visited'); - } - }, [path]); -} - -function PasswordConfirmStep({ errors, registerField, onChange, value }: PasswordConfirmStepProps) { - const { basePath } = useContext(FlowContext); - const { onPageTransition } = useContext(FormStepsContext); - const { addressVerificationMethod } = useContext(AddressVerificationMethodContext); - const stepPath = `${basePath}/password_confirm`; - const [path] = useHistoryParam(undefined, { basePath: stepPath }); - useDidUpdateEffect(onPageTransition, [path]); - useSubpageEventLogger(path); - - if (path === FORGOT_PASSWORD_PATH) { - return ; - } - - const appName = getConfigValue('appName'); - const stepErrors = errors.filter(({ error }) => error instanceof PasswordSubmitError); - - return ( - <> - {addressVerificationMethod === 'phone' && ( - - {formatHTML( - t('idv.messages.review.info_verified_html', { - phone_message: `${t('idv.messages.phone.phone_of_record')}`, - }), - { strong: 'strong' }, - )} - - )} - {stepErrors.length > 0 && ( - - {stepErrors.map(({ error }) => ( - - {error.message} - - ))} - - )} - {t('idv.titles.session.review', { app_name: appName })} -

{t('idv.messages.sessions.review_message', { app_name: appName })}

-

- - {t('idv.messages.sessions.read_more_encrypt', { app_name: appName })} - -

- ) => { - onChange({ password: event.target.value }); - }} - className="margin-top-6" - required - /> -
- - {t('idv.forgot_password.link_text')} - -
- - - - - - - ); -} - -export default PasswordConfirmStep; diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/password-reset-button.spec.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/password-reset-button.spec.tsx deleted file mode 100644 index 58790a58bc9..00000000000 --- a/app/javascript/packages/verify-flow/steps/password-confirm/password-reset-button.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useSandbox } from '@18f/identity-test-helpers'; -import PasswordResetButton, { API_ENDPOINT } from './password-reset-button'; -import * as api from '../../services/api'; -import FlowContext, { FlowContextValue } from '../../context/flow-context'; - -describe('PasswordResetButton', () => { - const sandbox = useSandbox(); - - const REDIRECT_URL = '/password_reset'; - - before(() => { - sandbox - .stub(api, 'post') - .withArgs(API_ENDPOINT, sandbox.match.any) - .resolves({ redirect_url: REDIRECT_URL }); - }); - - it('triggers password reset API call and redirects', async () => { - const onComplete = sandbox.stub() as FlowContextValue['onComplete']; - - const { getByRole } = render( - - - , - ); - - const button = getByRole('button'); - await Promise.all([ - userEvent.click(button), - expect(onComplete).to.eventually.be.calledWith({ completionURL: REDIRECT_URL }), - ]); - }); -}); diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/password-reset-button.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/password-reset-button.tsx deleted file mode 100644 index 42a116ebde7..00000000000 --- a/app/javascript/packages/verify-flow/steps/password-confirm/password-reset-button.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useContext } from 'react'; -import { SpinnerButton } from '@18f/identity-spinner-button'; -import { t } from '@18f/identity-i18n'; -import { isErrorResponse, post } from '../../services/api'; -import type { ErrorResponse } from '../../services/api'; -import FlowContext from '../../context/flow-context'; - -/** - * API endpoint for password reset. - */ -export const API_ENDPOINT = '/api/verify/v2/password_reset'; - -/** - * API response shape. - */ -interface PasswordResetSuccessResponse { - redirect_url: string; -} - -/** - * API response shape. - */ -type PasswordResetResponse = PasswordResetSuccessResponse | ErrorResponse; - -function PasswordResetButton() { - const { onComplete } = useContext(FlowContext); - - async function requestReset() { - const json = await post(API_ENDPOINT, {}, { csrf: true, json: true }); - if (!isErrorResponse(json)) { - const { redirect_url: completionURL } = json; - onComplete({ completionURL }); - } - } - - return ( - - {t('idv.forgot_password.reset_password')} - - ); -} - -export default PasswordResetButton; diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/personal-info-summary.spec.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/personal-info-summary.spec.tsx deleted file mode 100644 index ec74081e33e..00000000000 --- a/app/javascript/packages/verify-flow/steps/password-confirm/personal-info-summary.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { render } from '@testing-library/react'; -import PersonalInfoSummary from './personal-info-summary'; -import type { VerifyFlowValues } from '../../verify-flow'; - -describe('PersonalInfoSummary', () => { - const DEFAULT_PII: VerifyFlowValues = { - firstName: 'FAKEY', - lastName: 'MCFAKERSON', - address1: '1 FAKE RD', - city: 'GREAT FALLS', - state: 'MT', - zipcode: '59010', - dob: '1938-10-06', - }; - - it('renders dates accurately', () => { - const { getByText } = render(); - - expect(getByText('October 6, 1938')).to.exist(); - }); - - it('renders address', () => { - const { getByText, rerender } = render(); - - let address = getByText('1 FAKE RDGREAT FALLS, MT 59010'); - - expect([...address.childNodes].filter((node) => node.nodeName === 'BR')).to.have.lengthOf(1); - - rerender(); - - address = getByText('1 FAKE RDPO BOX 1GREAT FALLS, MT 59010'); - - expect([...address.childNodes].filter((node) => node.nodeName === 'BR')).to.have.lengthOf(2); - }); -}); diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/personal-info-summary.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/personal-info-summary.tsx deleted file mode 100644 index 9cf60c81cd7..00000000000 --- a/app/javascript/packages/verify-flow/steps/password-confirm/personal-info-summary.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { parse, format } from 'libphonenumber-js'; -import { t } from '@18f/identity-i18n'; -import type { VerifyFlowValues } from '../../verify-flow'; - -interface PersonalInfoSummaryProps { - pii: VerifyFlowValues; -} - -function PersonalInfoSummary({ pii }: PersonalInfoSummaryProps) { - const { firstName, lastName, dob, address1, address2, city, state, zipcode, ssn, phone } = pii; - const phoneNumber = parse(`+1${phone}`); - const formatted = format(phoneNumber, 'NATIONAL'); - - function getDateFormat(date: string) { - return new Date(date).toLocaleDateString(document.documentElement.lang, { - year: 'numeric', - month: 'long', - day: 'numeric', - timeZone: 'UTC', - }); - } - - return ( -
-
{t('idv.review.full_name')}
-
- {firstName} {lastName} -
-
{t('idv.review.mailing_address')}
-
- {address1} -
- {address2 && ( - <> - {address2} -
- - )} - {city && state ? `${city}, ${state} ${zipcode}` : ''} -
- {dob && ( - <> -
{t('idv.review.dob')}
-
{getDateFormat(dob)}
- - )} -
{t('idv.review.ssn')}
-
{ssn}
- {phone && ( - <> -
{t('idv.messages.phone.phone_of_record')}
-
{formatted}
- - )} -
- ); -} - -export default PersonalInfoSummary; diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/submit.spec.ts b/app/javascript/packages/verify-flow/steps/password-confirm/submit.spec.ts deleted file mode 100644 index 046b728ddd4..00000000000 --- a/app/javascript/packages/verify-flow/steps/password-confirm/submit.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { FormError } from '@18f/identity-form-steps'; -import { useSandbox } from '@18f/identity-test-helpers'; -import submit, { API_ENDPOINT } from './submit'; -import * as api from '../../services/api'; - -describe('submit', () => { - const sandbox = useSandbox(); - - context('with successful submission', () => { - beforeEach(() => { - sandbox - .stub(api, 'post') - .withArgs(API_ENDPOINT, { - user_bundle_token: '..', - password: 'hunter2', - }) - .resolves({ - personal_key: '0000-0000-0000-0000', - completion_url: 'http://example.com', - }); - }); - - it('sends with password confirmation values', async () => { - const patch = await submit({ userBundleToken: '..', password: 'hunter2' }); - - expect(patch).to.deep.equal({ - personalKey: '0000-0000-0000-0000', - completionURL: 'http://example.com', - }); - }); - }); - - context('error submission', () => { - beforeEach(() => { - sandbox - .stub(api, 'post') - .withArgs(API_ENDPOINT, { - user_bundle_token: '..', - password: 'hunter2', - }) - .resolves({ errors: { password: ['incorrect password'] } }); - }); - - it('throws error for the offending field', async () => { - const didError = await submit({ userBundleToken: '..', password: 'hunter2' }).catch( - (error: FormError) => { - expect(error.field).to.equal('password'); - expect(error.message).to.equal('incorrect password'); - return true; - }, - ); - - expect(didError).to.be.true(); - }); - }); -}); diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/submit.ts b/app/javascript/packages/verify-flow/steps/password-confirm/submit.ts deleted file mode 100644 index ce36e1ab572..00000000000 --- a/app/javascript/packages/verify-flow/steps/password-confirm/submit.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { FormError } from '@18f/identity-form-steps'; -import { post, ErrorResponse, isErrorResponse } from '../../services/api'; -import type { VerifyFlowValues } from '../../verify-flow'; - -/** - * API endpoint for password confirmation submission. - */ -export const API_ENDPOINT = '/api/verify/v2/password_confirm'; - -export class PasswordSubmitError extends FormError {} - -/** - * Successful API response shape. - */ -interface PasswordConfirmSuccessResponse { - /** - * Personal key generated for the user profile. - */ - personal_key: string; - - /** - * Final redirect URL for this verification session. - */ - completion_url: string; -} - -/** - * Failed API response shape. - */ -type PasswordConfirmErrorResponse = ErrorResponse<'password'>; - -/** - * API response shape. - */ -type PasswordConfirmResponse = PasswordConfirmSuccessResponse | PasswordConfirmErrorResponse; - -async function submit({ - userBundleToken, - password, -}: VerifyFlowValues): Promise> { - const payload = { user_bundle_token: userBundleToken, password }; - const json = await post(API_ENDPOINT, payload, { - json: true, - csrf: true, - }); - - if (isErrorResponse(json)) { - const [field, [error]] = Object.entries(json.errors)[0]; - throw new PasswordSubmitError(error, { field }); - } - - return { personalKey: json.personal_key, completionURL: json.completion_url }; -} - -export default submit; diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts b/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts deleted file mode 100644 index 6ae5e247e74..00000000000 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { t } from '@18f/identity-i18n'; -import type { FormStep } from '@18f/identity-form-steps'; -import type { VerifyFlowValues } from '../../verify-flow'; -import form from './personal-key-confirm-step'; - -export default { - name: 'personal_key_confirm', - title: t('titles.idv.personal_key'), - form, -} as FormStep; diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx deleted file mode 100644 index 89bd0d24903..00000000000 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import sinon from 'sinon'; -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { FormSteps } from '@18f/identity-form-steps'; -import * as analytics from '@18f/identity-analytics'; -import PersonalKeyConfirmStep from './personal-key-confirm-step'; - -describe('PersonalKeyConfirmStep', () => { - const DEFAULT_PROPS = { - onChange() {}, - value: { personalKey: '' }, - errors: [], - unknownFieldErrors: [], - onError() {}, - registerField: () => () => {}, - }; - - const sandbox = sinon.createSandbox(); - - beforeEach(() => { - sandbox.spy(analytics, 'trackEvent'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('allows the user to return to the previous step by clicking "Back" button', async () => { - const toPreviousStep = sinon.spy(); - const { getByText } = render( - , - ); - - await userEvent.click(getByText('forms.buttons.back')); - - expect(toPreviousStep).to.have.been.called(); - }); - - it('allows the user to return to the previous step by pressing Escape', async () => { - const toPreviousStep = sinon.spy(); - const { getByRole } = render( - , - ); - - await userEvent.type(getByRole('textbox'), '{Escape}'); - - expect(toPreviousStep).to.have.been.called(); - }); - - it('calls trackEvent when user dismisses modal by pressing "Back" button', async () => { - const toPreviousStep = sinon.spy(); - - const { getByText } = render( - , - ); - - await userEvent.click(getByText('forms.buttons.back')); - expect(analytics.trackEvent).to.have.been.calledWith('IdV: hide personal key modal'); - }); - - it('passes the value the user has entered to this point to the child PersonalKeyInput', () => { - const props = { - ...DEFAULT_PROPS, - value: { - personalKey: '', - personalKeyConfirm: '1234-asdf', - }, - toPreviousStep: () => {}, - }; - - const { getByRole } = render(); - - const input = getByRole('textbox') as HTMLInputElement; - - expect(input.value).to.equal('1234-asdf-'); - }); - - it('allows the user to continue only with a correct value', async () => { - const onComplete = sinon.spy(); - const { getByLabelText, getAllByText, container } = render( - , - ); - - const input = getByLabelText('forms.personal_key.confirmation_label'); - const continueButton = getAllByText('forms.buttons.continue')[1]; - await userEvent.click(continueButton); - - expect(onComplete).not.to.have.been.called(); - expect(container.ownerDocument.activeElement).to.equal(input); - let errorMessage = document.getElementById(input.getAttribute('aria-describedby')!); - expect(errorMessage!.textContent).to.equal('users.personal_key.confirmation_error'); - - await userEvent.type(input, '0000-0000-0000-000'); - errorMessage = document.getElementById(input.getAttribute('aria-describedby')!); - expect(errorMessage?.style.display === 'none').to.be.true(); - await userEvent.type(input, '{Enter}'); - expect(onComplete).not.to.have.been.called(); - errorMessage = document.getElementById(input.getAttribute('aria-describedby')!); - expect(errorMessage!.textContent).to.equal('users.personal_key.confirmation_error'); - - await userEvent.type(input, '0'); - - await userEvent.type(input, '{Enter}'); - expect(onComplete).to.have.been.calledOnce(); - - await userEvent.click(continueButton); - expect(onComplete).to.have.been.calledTwice(); - }); -}); diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx deleted file mode 100644 index 73279618e28..00000000000 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Button } from '@18f/identity-components'; -import { FormStepsButton } from '@18f/identity-form-steps'; -import { t } from '@18f/identity-i18n'; -import type { FormStepComponentProps } from '@18f/identity-form-steps'; -import { Modal } from '@18f/identity-modal'; -import { getAssetPath } from '@18f/identity-assets'; -import { trackEvent } from '@18f/identity-analytics'; -import PersonalKeyStep from '../personal-key/personal-key-step'; -import PersonalKeyInput from './personal-key-input'; -import type { VerifyFlowValues } from '../../verify-flow'; - -interface PersonalKeyConfirmStepProps extends FormStepComponentProps {} - -function PersonalKeyConfirmStep(stepProps: PersonalKeyConfirmStepProps) { - const { registerField, value, onChange, toPreviousStep } = stepProps; - const personalKey = value.personalKey!; - const enteredPersonalKey = value.personalKeyConfirm; - - const closeModalActions = () => { - trackEvent('IdV: hide personal key modal'); - toPreviousStep(); - }; - - return ( - <> - - -
- {t('image_description.personal_key')} -
- {t('forms.personal_key.title')} - {t('forms.personal_key.instructions')} - {/* Because the Modal renders into a portal outside the flow form, inputs would not normally - emit a submit event. We can reinstate the expected behavior with an empty form. A submit - event will bubble through the React portal boundary and be handled by FormSteps. Because - the form is not rendered in the same DOM hierarchy, it is not invalid nesting. */} -
- onChange({ personalKeyConfirm })} - /> -
-
- -
-
- -
-
- -
-
- -
- - ); -} - -export default PersonalKeyConfirmStep; diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.spec.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.spec.tsx deleted file mode 100644 index a4282e142da..00000000000 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.spec.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import PersonalKeyInput from './personal-key-input'; - -describe('PersonalKeyInput', () => { - it('accepts a value with dashes', async () => { - const value = '0000-0000-0000-0000'; - const { getByRole } = render(); - - const input = getByRole('textbox') as HTMLInputElement; - await userEvent.type(input, value); - - expect(input.value).to.equal(value); - }); - - it('accepts a value without dashes', async () => { - const { getByRole } = render(); - - const input = getByRole('textbox') as HTMLInputElement; - await userEvent.type(input, '0000000000000000'); - - expect(input.value).to.equal('0000-0000-0000-0000'); - }); - - it('does not accept a code longer than one with dashes', async () => { - const { getByRole } = render(); - - const input = getByRole('textbox') as HTMLInputElement; - await userEvent.type(input, '0000-0000-0000-00000'); - - expect(input.value).to.equal('0000-0000-0000-0000'); - }); - - it('formats value as the user types', async () => { - const { getByRole } = render(); - - const input = getByRole('textbox') as HTMLInputElement; - - await userEvent.type(input, '1234'); - expect(input.value).to.equal('1234-'); - - await userEvent.type(input, '-'); - expect(input.value).to.equal('1234-'); - - await userEvent.paste('12341234'); - expect(input.value).to.equal('1234-1234-1234-'); - - await userEvent.type(input, '12345'); - expect(input.value).to.equal('1234-1234-1234-1234'); - }); - - it('allows the user to paste the personal key from their clipboard', async () => { - const { getByRole } = render(); - - const input = getByRole('textbox') as HTMLInputElement; - - input.focus(); - await userEvent.paste('1234-1234-1234-1234'); - - expect(input.value).to.equal('1234-1234-1234-1234'); - }); - - it('validates the input value against the expected value (case-insensitive, crockford)', async () => { - const { getByRole } = render(); - - const input = getByRole('textbox') as HTMLInputElement; - - await userEvent.type(input, 'ABCDoOlL-defg-iI1'); - input.checkValidity(); - expect(input.validationMessage).to.equal('users.personal_key.confirmation_error'); - - await userEvent.type(input, '1'); - input.checkValidity(); - expect(input.validity.valid).to.be.true(); - }); - - it('renders the input with an initial value if one is provided', () => { - const { getByRole } = render( - , - ); - const input = getByRole('textbox') as HTMLInputElement; - - expect(input.value).to.equal('1234-asdf-'); - }); -}); diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.tsx deleted file mode 100644 index cdbc3bca29d..00000000000 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { forwardRef, useCallback } from 'react'; -import type { ForwardedRef } from 'react'; -import Cleave from 'cleave.js/react'; -import type { ReactInstanceWithCleave } from 'cleave.js/react/props'; -import { t } from '@18f/identity-i18n'; -import { ValidatedField } from '@18f/identity-validated-field'; -import type { ValidatedFieldValidator } from '@18f/identity-validated-field'; - -/** - * Internal Cleave.js React instance API methods. - */ -interface CleaveInstanceInternalAPI { - updateValueState: () => void; -} - -interface PersonalKeyInputProps { - /** - * The personal key to initially render the form with. This value is used when the user has - * entered a partial personal key and clicked the "back" button. - */ - value?: string; - - /** - * The correct personal key to validate against. - */ - expectedValue?: string; - - /** - * Callback invoked when the value of the input has changed. - */ - onChange?: (nextValue: string) => void; -} - -/** - * Normalize an input value for validation comparison. - * - * @param string Denormalized value. - * - * @return Normalized value. - */ -const normalize = (string: string) => string.toLowerCase().replace(/o/g, '0').replace(/[il]/g, '1'); - -function PersonalKeyInput( - { value, expectedValue, onChange = () => {} }: PersonalKeyInputProps, - ref: ForwardedRef, -) { - const validate = useCallback( - (personalKey) => { - if (expectedValue && normalize(personalKey) !== normalize(expectedValue)) { - throw new Error(t('users.personal_key.confirmation_error')); - } - }, - [expectedValue], - ); - - return ( - - { - (owner as ReactInstanceWithCleave & CleaveInstanceInternalAPI).updateValueState(); - }} - value={value} - options={{ - blocks: [4, 4, 4, 4], - delimiter: '-', - }} - htmlRef={(cleaveRef) => typeof ref === 'function' && ref(cleaveRef)} - aria-label={t('forms.personal_key.confirmation_label')} - autoComplete="off" - className="width-full field font-family-mono text-uppercase" - pattern="[a-zA-Z0-9-]+" - spellCheck={false} - type="text" - onInput={(event) => onChange((event.target as HTMLInputElement).value)} - /> - - ); -} - -export default forwardRef(PersonalKeyInput); diff --git a/app/javascript/packages/verify-flow/steps/personal-key/download-button.spec.tsx b/app/javascript/packages/verify-flow/steps/personal-key/download-button.spec.tsx deleted file mode 100644 index 23e41e1c5a6..00000000000 --- a/app/javascript/packages/verify-flow/steps/personal-key/download-button.spec.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import sinon from 'sinon'; -import { render, fireEvent, createEvent } from '@testing-library/react'; -import { usePropertyValue } from '@18f/identity-test-helpers'; -import DownloadButton, { hasProprietarySaveBlob } from './download-button'; -import type { NavigatorWithSaveBlob } from './download-button'; - -describe('DownloadButton', () => { - it('renders a link to download the given content as file', () => { - const { getByRole } = render(); - - const link = getByRole('link'); - - expect(link.getAttribute('download')).to.equal('example.txt'); - expect(link.getAttribute('href')).to.equal('data:,Hello%2C%20world!'); - }); - - it('renders with download icon', () => { - const { getByRole } = render(); - - const icon = getByRole('img', { hidden: true }); - - expect(icon.classList.contains('usa-icon')).to.be.true(); - expect(icon.querySelector('use[href$="#file_download"]')); - }); - - it('does not prevent default when clicked', () => { - const { getByRole } = render(); - - const link = getByRole('link'); - const clickEvent = createEvent('click', link, { bubbles: true, cancelable: true }); - fireEvent(link, clickEvent); - - expect(clickEvent.defaultPrevented).to.be.false(); - }); - - it('calls onClick prop if given', () => { - const onClick = sinon.stub(); - const { getByRole } = render( - , - ); - - const link = getByRole('link'); - const clickEvent = createEvent('click', link, { bubbles: true, cancelable: true }); - fireEvent(link, clickEvent); - - expect(onClick).to.have.been.called(); - expect(onClick.getCall(0).args[0].nativeEvent).to.equal(clickEvent); - }); - - describe('hasProprietarySaveBlob()', () => { - it('returns false', () => { - expect(hasProprietarySaveBlob(window.navigator)).to.be.false(); - }); - }); - - context('in internet explorer', () => { - usePropertyValue(window.navigator as NavigatorWithSaveBlob, 'msSaveBlob', sinon.stub()); - - it('intercepts click to download file using proprietary API', () => { - const onClick = sinon.stub(); - const { getByRole } = render( - , - ); - - const link = getByRole('link'); - const clickEvent = createEvent('click', link, { bubbles: true, cancelable: true }); - fireEvent(link, clickEvent); - - expect(onClick).to.have.been.called(); - expect(onClick.getCall(0).args[0].nativeEvent).to.equal(clickEvent); - expect(clickEvent.defaultPrevented).to.be.true(); - expect((window.navigator as NavigatorWithSaveBlob).msSaveBlob).to.have.been.called(); - }); - - describe('hasProprietarySaveBlob()', () => { - it('returns true', () => { - expect(hasProprietarySaveBlob(window.navigator)).to.be.true(); - }); - }); - }); -}); diff --git a/app/javascript/packages/verify-flow/steps/personal-key/download-button.tsx b/app/javascript/packages/verify-flow/steps/personal-key/download-button.tsx deleted file mode 100644 index 66ea4bd8ade..00000000000 --- a/app/javascript/packages/verify-flow/steps/personal-key/download-button.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { MouseEventHandler } from 'react'; -import { Button } from '@18f/identity-components'; -import type { ButtonProps } from '@18f/identity-components'; - -interface DownloadButtonProps { - /** - * Content of the downloaded file. - */ - content: string; - - /** - * File name to use for downloaded file. - */ - fileName: string; -} - -export interface NavigatorWithSaveBlob extends Navigator { - msSaveBlob: (blob: Blob, filename: string) => void; -} - -export const hasProprietarySaveBlob = ( - navigator: Navigator | NavigatorWithSaveBlob, -): navigator is NavigatorWithSaveBlob => 'msSaveBlob' in navigator; - -function DownloadButton({ content, fileName, ...buttonProps }: DownloadButtonProps & ButtonProps) { - const download: MouseEventHandler = (event) => { - buttonProps.onClick?.(event); - - if (hasProprietarySaveBlob(window.navigator)) { - event.preventDefault(); - const blob = new Blob([content], { type: 'text/plain' }); - window.navigator.msSaveBlob(blob, fileName); - } - }; - - return ( -