= {
- ...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();