diff --git a/app/controllers/idv/in_person/address_search_controller.rb b/app/controllers/idv/in_person/address_search_controller.rb index f1bc0151f2f..4b06a47476c 100644 --- a/app/controllers/idv/in_person/address_search_controller.rb +++ b/app/controllers/idv/in_person/address_search_controller.rb @@ -6,13 +6,13 @@ class AddressSearchController < ApplicationController check_or_render_not_found -> { IdentityConfig.store.arcgis_search_enabled } def index - render json: addresses + render json: addresses(params[:address]) end protected - def addresses - suggestion = geocoder.suggest(permitted_params[:address]).first + def addresses(search_term) + suggestion = geocoder.suggest(search_term).first return [] unless suggestion geocoder.find_address_candidates(suggestion.magic_key).slice(0, 1) rescue Faraday::ConnectionFailed @@ -22,10 +22,6 @@ def addresses def geocoder @geocoder ||= ArcgisApi::Geocoder.new end - - def permitted_params - params.permit(:address) - end end end end diff --git a/app/javascript/packages/components/index.ts b/app/javascript/packages/components/index.ts index e4de7ac68ec..776e12c5b4a 100644 --- a/app/javascript/packages/components/index.ts +++ b/app/javascript/packages/components/index.ts @@ -19,8 +19,10 @@ export { default as ScrollIntoView } from './scroll-into-view'; export { default as SpinnerDots } from './spinner-dots'; export { default as StatusPage } from './status-page'; export { default as Tag } from './tag'; +export { default as TextInput } from './text-input'; export { default as TroubleshootingOptions } from './troubleshooting-options'; export type { ButtonProps } from './button'; export type { FullScreenRefHandle } from './full-screen'; export type { LinkProps } from './link'; +export type { TextInputProps } from './text-input'; diff --git a/app/javascript/packages/components/text-input.spec.tsx b/app/javascript/packages/components/text-input.spec.tsx new file mode 100644 index 00000000000..310a3cab1d6 --- /dev/null +++ b/app/javascript/packages/components/text-input.spec.tsx @@ -0,0 +1,48 @@ +import { createRef } from 'react'; +import { render } from '@testing-library/react'; +import TextInput from './text-input'; + +describe('TextInput', () => { + it('renders with an associated label', () => { + const { getByLabelText } = render(); + + const input = getByLabelText('Input'); + + expect(input).to.be.an.instanceOf(HTMLInputElement); + expect(input.classList.contains('usa-input')).to.be.true(); + }); + + it('uses an explicitly-provided ID', () => { + const customId = 'custom-id'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input'); + + expect(input.id).to.equal(customId); + }); + + it('applies additional given classes', () => { + const customClass = 'custom-class'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input'); + + expect([...input.classList.values()]).to.have.all.members(['usa-input', customClass]); + }); + + it('applies additional input attributes', () => { + const type = 'password'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input') as HTMLInputElement; + + expect(input.type).to.equal(type); + }); + + it('forwards ref', () => { + const ref = createRef(); + render(); + + expect(ref.current).to.be.an.instanceOf(HTMLInputElement); + }); +}); diff --git a/app/javascript/packages/components/text-input.tsx b/app/javascript/packages/components/text-input.tsx new file mode 100644 index 00000000000..31f07aa42a8 --- /dev/null +++ b/app/javascript/packages/components/text-input.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from 'react'; +import type { InputHTMLAttributes, ForwardedRef } from 'react'; +import { useInstanceId } from '@18f/identity-react-hooks'; + +export interface TextInputProps extends InputHTMLAttributes { + /** + * Text of label associated with input. + */ + label: string; + + /** + * Optional explicit ID to use in place of default behavior. + */ + id?: string; + + /** + * Additional class name to be applied to the input element. + */ + className?: string; +} + +function TextInput( + { label, id, className, ...inputProps }: TextInputProps, + ref: ForwardedRef, +) { + const instanceId = useInstanceId(); + const inputId = id ?? `text-input-${instanceId}`; + const classes = ['usa-input', className].filter(Boolean).join(' '); + + return ( + <> + + + + ); +} + +export default forwardRef(TextInput); diff --git a/app/javascript/packages/document-capture/components/address-search.tsx b/app/javascript/packages/document-capture/components/address-search.tsx new file mode 100644 index 00000000000..47aa56f497f --- /dev/null +++ b/app/javascript/packages/document-capture/components/address-search.tsx @@ -0,0 +1,51 @@ +import { TextInput, Button } from '@18f/identity-components'; +import { request } from '@18f/identity-request'; +import { useState, useCallback, ChangeEvent } from 'react'; + +interface Location { + street_address: string; + city: string; + state: string; + zip_code: string; + address: string; +} + +interface AddressSearchProps { + onAddressFound?: (location: Location) => void; +} + +export const ADDRESS_SEARCH_URL = '/api/addresses'; + +function AddressSearch({ onAddressFound = () => {} }: AddressSearchProps) { + const [unvalidatedAddressInput, setUnvalidatedAddressInput] = useState(''); + const [addressQuery, setAddressQuery] = useState({} as Location); + const handleAddressSearch = useCallback(async () => { + const addressCandidates = await request(ADDRESS_SEARCH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + json: { address: unvalidatedAddressInput }, + }); + + const bestMatchedAddress = addressCandidates[0]; + setAddressQuery(bestMatchedAddress); + onAddressFound(bestMatchedAddress); + }, [unvalidatedAddressInput]); + + return ( + <> + { + const target = event.target as HTMLInputElement; + + setUnvalidatedAddressInput(target.value); + }} + label="Search for an address" + /> + + <>{addressQuery.address} + + ); +} + +export default AddressSearch; diff --git a/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx index 25bed2d9031..a0c3610ed49 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-step.spec.tsx @@ -6,6 +6,8 @@ import userEvent from '@testing-library/user-event'; import { useSandbox } from '@18f/identity-test-helpers'; import AnalyticsContext, { AnalyticsContextProvider } from '../context/analytics'; import InPersonLocationStep, { LOCATIONS_URL } from './in-person-location-step'; +import { ADDRESS_SEARCH_URL } from './address-search'; +import InPersonContext from '../context/in-person'; describe('InPersonLocationStep', () => { const DEFAULT_PROPS = { toPreviousStep() {}, onChange() {}, value: {} }; @@ -18,6 +20,20 @@ describe('InPersonLocationStep', () => { .withArgs(LOCATIONS_URL) .resolves({ json: () => Promise.resolve([{ name: 'Baltimore' }]), + } as Response) + .withArgs(ADDRESS_SEARCH_URL) + .resolves({ + json: () => + Promise.resolve([ + { + address: '100 Main St, South Fulton, Tennessee, 38257', + location: { latitude: 36.501462000000004, longitude: -88.875981 }, + street_address: '100 Main St', + city: 'South Fulton', + state: 'TN', + zip_code: '38257', + }, + ]), } as Response); }); @@ -40,4 +56,23 @@ describe('InPersonLocationStep', () => { await findByText('{"selected_location":"Baltimore"}'); }); + + it('allows search by address when enabled', async () => { + const { findByText, findByLabelText } = render( + + + , + ); + + await userEvent.type(await findByLabelText('Search for an address'), '100 main'); + await userEvent.click(await findByText('Search')); + await findByText('100 Main St, South Fulton, Tennessee, 38257'); + expect(window.fetch).to.have.been.calledWith( + LOCATIONS_URL, + sandbox.match({ + body: '{"address":{"street_address":"100 Main St","city":"South Fulton","state":"TN","zip_code":"38257"}}', + method: 'post', + }), + ); + }); }); diff --git a/app/javascript/packages/document-capture/components/in-person-location-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-step.tsx index 243419f0003..89d19283591 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-step.tsx @@ -1,10 +1,13 @@ import { useState, useEffect, useCallback, useRef, useContext } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; import { PageHeading, SpinnerDots } from '@18f/identity-components'; +import { request } from '@18f/identity-request'; import BackButton from './back-button'; import LocationCollection from './location-collection'; import LocationCollectionItem from './location-collection-item'; import AnalyticsContext from '../context/analytics'; +import AddressSearch from './address-search'; +import InPersonContext from '../context/in-person'; interface PostOffice { address: string; @@ -29,64 +32,20 @@ interface FormattedLocation { sundayHours: string; weekdayHours: string; } - -interface RequestOptions { - /** - * Whether to send the request as a JSON request. Defaults to true. - */ - json?: boolean; - - /** - * Whether to include CSRF token in the request. Defaults to true. - */ - csrf?: boolean; - - /** - * Optional. HTTP verb used. Defaults to GET. - */ - method?: string; +interface LocationQuery { + streetAddress: string; + city: string; + state: string; + zipCode: string; } -const DEFAULT_FETCH_OPTIONS = { csrf: true, json: true }; - -const getCSRFToken = () => - document.querySelector('meta[name="csrf-token"]')?.content; - -const request = async ( - url: string, - body: BodyInit | object, - options: Partial = {}, -) => { - const headers: HeadersInit = {}; - const mergedOptions: Partial = { - ...DEFAULT_FETCH_OPTIONS, - ...options, - }; - - if (mergedOptions.csrf) { - const csrf = getCSRFToken(); - if (csrf) { - headers['X-CSRF-Token'] = csrf; - } - } - - if (mergedOptions.json) { - headers['Content-Type'] = 'application/json'; - body = JSON.stringify(body); - } - - const response = await window.fetch(url, { - method: mergedOptions.method, - headers, - body: body as BodyInit, - }); - - return mergedOptions.json ? response.json() : response.text(); -}; - export const LOCATIONS_URL = '/verify/in_person/usps_locations'; -const getUspsLocations = () => request(LOCATIONS_URL, {}, { method: 'post' }); +const getUspsLocations = (address) => + request(LOCATIONS_URL, { + method: 'post', + json: { address }, + }); const formatLocation = (postOffices: PostOffice[]) => { const formattedLocations = [] as FormattedLocation[]; @@ -124,10 +83,12 @@ const prepToSend = (location: object) => { function InPersonLocationStep({ onChange, toPreviousStep }) { const { t } = useI18n(); const [locationData, setLocationData] = useState([] as FormattedLocation[]); + const [foundAddress, setFoundAddress] = useState({} as LocationQuery); const [inProgress, setInProgress] = useState(false); const [autoSubmit, setAutoSubmit] = useState(false); const [isLoadingComplete, setIsLoadingComplete] = useState(false); const { setSubmitEventMetadata } = useContext(AnalyticsContext); + const { arcgisSearchEnabled } = useContext(InPersonContext); // ref allows us to avoid a memory leak const mountedRef = useRef(false); @@ -156,7 +117,8 @@ function InPersonLocationStep({ onChange, toPreviousStep }) { } const selected = prepToSend(selectedLocation); setInProgress(true); - await request(LOCATIONS_URL, selected, { + await request(LOCATIONS_URL, { + json: selected, method: 'PUT', }) .then(() => { @@ -181,26 +143,35 @@ function InPersonLocationStep({ onChange, toPreviousStep }) { [locationData, inProgress], ); + const handleFoundAddress = useCallback((address) => { + setFoundAddress({ + streetAddress: address.street_address, + city: address.city, + state: address.state, + zipCode: address.zip_code, + }); + }, []); + useEffect(() => { - let mounted = true; + let didCancel = false; (async () => { try { - const fetchedLocations = await getUspsLocations(); + const fetchedLocations = await getUspsLocations(prepToSend(foundAddress)); - if (mounted) { + if (!didCancel) { const formattedLocations = formatLocation(fetchedLocations); setLocationData(formattedLocations); } } finally { - if (mounted) { + if (!didCancel) { setIsLoadingComplete(true); } } })(); return () => { - mounted = false; + didCancel = true; }; - }, []); + }, [foundAddress]); let locationsContent: React.ReactNode; if (!isLoadingComplete) { @@ -230,6 +201,7 @@ function InPersonLocationStep({ onChange, toPreviousStep }) { return ( <> {t('in_person_proofing.headings.location')} + {arcgisSearchEnabled && }

{t('in_person_proofing.body.location.location_step_about')}

{locationsContent} diff --git a/app/javascript/packages/document-capture/context/in-person.js b/app/javascript/packages/document-capture/context/in-person.js new file mode 100644 index 00000000000..06ebb054eaf --- /dev/null +++ b/app/javascript/packages/document-capture/context/in-person.js @@ -0,0 +1,15 @@ +import { createContext } from 'react'; + +/** + * @typedef InPersonContext + * + * @prop {boolean} arcgisSearchEnabled feature flag for enabling address search + */ + +const InPersonContext = createContext( + /** @type {InPersonContext} */ ({ arcgisSearchEnabled: false }), +); + +InPersonContext.displayName = 'InPersonContext'; + +export default InPersonContext; diff --git a/app/javascript/packages/document-capture/context/index.ts b/app/javascript/packages/document-capture/context/index.ts index e3aaaf74af7..1f0c1b35ef8 100644 --- a/app/javascript/packages/document-capture/context/index.ts +++ b/app/javascript/packages/document-capture/context/index.ts @@ -24,3 +24,4 @@ export { default as AcuantSdkUpgradeABTestContext, Provider as AcuantSdkUpgradeABTestContextProvider, } from './native-camera-a-b-test'; +export { default as InPersonContext } from './in-person'; diff --git a/app/javascript/packages/request/README.md b/app/javascript/packages/request/README.md new file mode 100644 index 00000000000..4c528354351 --- /dev/null +++ b/app/javascript/packages/request/README.md @@ -0,0 +1,7 @@ +# `@18f/identity-request` + +Wraps the native fetch API to include IDP-specific configuration. + +```js +request('http://api.com', { method: post, json: { some: 'POJO' }, csrf: true }) // includes the IDP CSRF and stringifies JSON. +``` diff --git a/app/javascript/packages/request/index.spec.ts b/app/javascript/packages/request/index.spec.ts new file mode 100644 index 00000000000..e18e0a5a414 --- /dev/null +++ b/app/javascript/packages/request/index.spec.ts @@ -0,0 +1,141 @@ +import { useSandbox } from '@18f/identity-test-helpers'; +import { request } from '.'; + +describe('request', () => { + const sandbox = useSandbox(); + + it('includes the CSRF token by default', async () => { + const csrf = 'TYsqyyQ66Y'; + const mockGetCSRF = () => csrf; + + sandbox.stub(window, 'fetch').callsFake((url, init = {}) => { + const headers = init.headers as Headers; + expect(headers.get('X-CSRF-Token')).to.equal(csrf); + + return Promise.resolve( + new Response(JSON.stringify({}), { + status: 200, + }), + ); + }); + + await request('https://example.com', { + csrf: mockGetCSRF, + }); + + expect(window.fetch).to.have.been.calledOnce(); + }); + it('works even if the CSRF token is not found on the page', async () => { + sandbox.stub(window, 'fetch').callsFake(() => + Promise.resolve( + new Response(JSON.stringify({}), { + status: 200, + }), + ), + ); + + await request('https://example.com', { + csrf: () => undefined, + }); + }); + it('does not try to send a csrf when csrf is false', async () => { + sandbox.stub(window, 'fetch').callsFake((url, init = {}) => { + const headers = init.headers as Headers; + expect(headers.get('X-CSRF-Token')).to.be.null(); + + return Promise.resolve( + new Response(JSON.stringify({}), { + status: 200, + }), + ); + }); + + await request('https://example.com', { + csrf: false, + }); + }); + it('prefers the json prop if both json and body props are provided', async () => { + const preferredData = { prefered: 'data' }; + sandbox.stub(window, 'fetch').callsFake((url, init = {}) => { + expect(init.body).to.equal(JSON.stringify(preferredData)); + + return Promise.resolve( + new Response(JSON.stringify({}), { + status: 200, + }), + ); + }); + + await request('https://example.com', { + json: preferredData, + body: JSON.stringify({ bad: 'data' }), + }); + }); + it('works with the native body prop', async () => { + const preferredData = { this: 'works' }; + sandbox.stub(window, 'fetch').callsFake((url, init = {}) => { + expect(init.body).to.equal(JSON.stringify(preferredData)); + + return Promise.resolve( + new Response(JSON.stringify({}), { + status: 200, + }), + ); + }); + + await request('https://example.com', { + body: JSON.stringify(preferredData), + }); + }); + it('includes additional headers supplied in options', async () => { + sandbox.stub(window, 'fetch').callsFake((url, init = {}) => { + const headers = init.headers as Headers; + expect(headers.get('Some-Fancy')).to.equal('Header'); + + return Promise.resolve( + new Response(JSON.stringify({}), { + status: 200, + }), + ); + }); + + await request('https://example.com', { + headers: { + 'Some-Fancy': 'Header', + }, + }); + }); + it('skips json serialization when json is a boolean', async () => { + const preferredData = { this: 'works' }; + sandbox.stub(window, 'fetch').callsFake((url, init = {}) => { + expect(init.body).to.equal(JSON.stringify(preferredData)); + + return Promise.resolve( + new Response(JSON.stringify({}), { + status: 200, + }), + ); + }); + + await request('https://example.com', { + json: true, + body: JSON.stringify(preferredData), + }); + }); + it('converts a POJO to a JSON string with supplied via the json property', async () => { + const preferredData = { this: 'works' }; + sandbox.stub(window, 'fetch').callsFake((url, init = {}) => { + expect(init.body).to.equal(JSON.stringify(preferredData)); + + return Promise.resolve( + new Response(JSON.stringify({}), { + status: 200, + }), + ); + }); + + await request('https://example.com', { + json: preferredData, + }); + }); +}); diff --git a/app/javascript/packages/request/index.ts b/app/javascript/packages/request/index.ts new file mode 100644 index 00000000000..411c08f5ad5 --- /dev/null +++ b/app/javascript/packages/request/index.ts @@ -0,0 +1,42 @@ +type CSRFGetter = () => string | undefined; + +interface RequestOptions extends RequestInit { + /** + * Either boolean or unstringified POJO to send with the request as JSON. Defaults to true. + */ + json?: object | boolean; + + /** + * Whether to include CSRF token in the request. Defaults to true. + */ + csrf?: boolean | CSRFGetter; +} + +const getCSRFToken = () => + document.querySelector('meta[name="csrf-token"]')?.content; + +export const request = async (url: string, options: Partial = {}) => { + const { csrf = true, json = true, ...fetchOptions } = options; + let { body, headers } = fetchOptions; + headers = new Headers(headers); + + if (csrf) { + const csrfToken = typeof csrf === 'boolean' ? getCSRFToken() : csrf(); + + if (csrfToken) { + headers.set('X-CSRF-Token', csrfToken); + } + } + + if (json) { + headers.set('Content-Type', 'application/json'); + headers.set('Accept', 'application/json'); + + if (typeof json !== 'boolean') { + body = JSON.stringify(json); + } + } + + const response = await window.fetch(url, { ...fetchOptions, headers, body }); + return json ? response.json() : response.text(); +}; diff --git a/app/javascript/packages/request/package.json b/app/javascript/packages/request/package.json new file mode 100644 index 00000000000..1a949d73073 --- /dev/null +++ b/app/javascript/packages/request/package.json @@ -0,0 +1,5 @@ +{ + "name": "@18f/identity-request", + "version": "1.0.0", + "private": true +} diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 1d86b87ca0b..881db164784 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -11,6 +11,7 @@ import { FailedCaptureAttemptsContextProvider, NativeCameraABTestContextProvider, MarketingSiteContextProvider, + InPersonContext, } from '@18f/identity-document-capture'; import { isCameraCapableMobile } from '@18f/identity-device'; import { FlowContext } from '@18f/identity-verify-flow'; @@ -126,6 +127,7 @@ const trackEvent: typeof baseTrackEvent = (event, payload) => { cancelUrl: cancelURL, idvInPersonUrl: inPersonURL, securityAndPrivacyHowItWorksUrl: securityAndPrivacyHowItWorksURL, + arcgisSearchEnabled, } = appRoot.dataset as DOMStringMap & AppRootData; const App = composeComponents( @@ -184,6 +186,7 @@ const trackEvent: typeof baseTrackEvent = (event, payload) => { nativeCameraOnly: nativeCameraOnly === 'true', }, ], + [InPersonContext.Provider, { value: { arcgisSearchEnabled: arcgisSearchEnabled === 'true' } }], [DocumentCapture, { isAsyncForm, onStepChange: keepAlive }], ); diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 0fba2ebffdc..8d3ade8af3c 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -45,6 +45,7 @@ keep_alive_endpoint: sessions_keepalive_url, idv_in_person_url: Idv::InPersonConfig.enabled_for_issuer?(decorated_session.sp_issuer) ? idv_in_person_url : nil, security_and_privacy_how_it_works_url: MarketingSite.security_and_privacy_how_it_works_url, + arcgis_search_enabled: IdentityConfig.store.arcgis_search_enabled, } %> <%= simple_form_for( :doc_auth, diff --git a/config/routes.rb b/config/routes.rb index a9731ec9ce5..b903faba44d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,7 @@ post '/api/service_provider' => 'service_provider#update' post '/api/verify/images' => 'idv/image_uploads#create' post '/api/logger' => 'frontend_log#create' + post '/api/addresses' => 'idv/in_person/address_search#index' get '/openid_connect/authorize' => 'openid_connect/authorization#index' get '/openid_connect/logout' => 'openid_connect/logout#index' diff --git a/package.json b/package.json index fc4f1cbaa99..f41fe1f7977 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,8 @@ "stylelint": "^14.13.0", "svgo": "^2.8.0", "typescript": "^4.8.4", - "webpack-dev-server": "^4.11.1" + "webpack-dev-server": "^4.11.1", + "whatwg-fetch": "^3.4.0" }, "resolutions": { "minimist": "1.2.6" diff --git a/spec/javascripts/spec_helper.js b/spec/javascripts/spec_helper.js index 2b5bd76815a..3ae9c6d2946 100644 --- a/spec/javascripts/spec_helper.js +++ b/spec/javascripts/spec_helper.js @@ -3,6 +3,7 @@ import chai from 'chai'; import dirtyChai from 'dirty-chai'; import sinonChai from 'sinon-chai'; import chaiAsPromised from 'chai-as-promised'; +import { Response } from 'whatwg-fetch'; import { createDOM, useCleanDOM } from './support/dom'; import { chaiConsoleSpy, useConsoleLogSpy } from './support/console'; import { sinonChaiAsPromised } from './support/sinon'; @@ -34,6 +35,7 @@ Object.defineProperty(global.window.Image.prototype, 'src', { this.onload(); }, }); +global.window.Response = Response; useCleanDOM(dom); useConsoleLogSpy();