diff --git a/app/controllers/api/verify/complete_controller.rb b/app/controllers/api/verify/password_confirm_controller.rb
similarity index 94%
rename from app/controllers/api/verify/complete_controller.rb
rename to app/controllers/api/verify/password_confirm_controller.rb
index 0cfd610a172..d05f2f40e4c 100644
--- a/app/controllers/api/verify/complete_controller.rb
+++ b/app/controllers/api/verify/password_confirm_controller.rb
@@ -1,6 +1,6 @@
module Api
module Verify
- class CompleteController < Api::BaseController
+ class PasswordConfirmController < Api::BaseController
def create
result, personal_key = Api::ProfileCreationForm.new(
password: verify_params[:password],
diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb
index eb304dde471..88cbdde639f 100644
--- a/app/controllers/idv/review_controller.rb
+++ b/app/controllers/idv/review_controller.rb
@@ -7,6 +7,7 @@ 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]
def confirm_idv_steps_complete
@@ -54,6 +55,11 @@ def create
private
+ 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 step_indicator_steps
steps = Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS
return steps if idv_session.address_verification_mechanism != 'gpo'
diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx
index ca056344a12..93368c86fee 100644
--- a/app/javascript/packages/form-steps/form-steps.spec.tsx
+++ b/app/javascript/packages/form-steps/form-steps.spec.tsx
@@ -176,6 +176,19 @@ describe('FormSteps', () => {
expect(getByText('Second Title')).to.be.ok();
});
+ it('uses submit implementation return value as patch to form values', async () => {
+ const steps = [
+ { ...STEPS[0], submit: () => Promise.resolve({ secondInputOne: 'received' }) },
+ STEPS[1],
+ ];
+ const { getByText, findByDisplayValue } = render();
+
+ const continueButton = getByText('forms.buttons.continue');
+ await userEvent.click(continueButton);
+
+ expect(await findByDisplayValue('received')).to.be.ok();
+ });
+
it('does not proceed if step submit implementation throws an error', async () => {
sandbox.useFakeTimers();
const steps = [
@@ -225,6 +238,18 @@ describe('FormSteps', () => {
expect(onStepChange.callCount).to.equal(1);
});
+ it('calls onChange with updated form values', async () => {
+ const onChange = sinon.spy();
+ const { getByText, getByLabelText } = render();
+
+ await userEvent.click(getByText('forms.buttons.continue'));
+ await userEvent.type(getByLabelText('Second Input One'), 'one');
+
+ expect(onChange).to.have.been.calledWith({ changed: true, secondInputOne: 'o' });
+ expect(onChange).to.have.been.calledWith({ changed: true, secondInputOne: 'on' });
+ expect(onChange).to.have.been.calledWith({ changed: true, secondInputOne: 'one' });
+ });
+
it('submits with form values', async () => {
const onComplete = sinon.spy();
const { getByText, getByLabelText } = render(
diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx
index 6ee7a0b034c..2acc963e9a8 100644
--- a/app/javascript/packages/form-steps/form-steps.tsx
+++ b/app/javascript/packages/form-steps/form-steps.tsx
@@ -88,7 +88,7 @@ export interface FormStep {
/**
* Optionally-asynchronous submission behavior, expected to throw any submission error.
*/
- submit?: (values: V) => void | Promise;
+ submit?: (values: V) => void | Record | Promise>;
/**
* Human-readable step label.
@@ -134,10 +134,15 @@ interface FormStepsProps {
*/
autoFocus?: boolean;
+ /**
+ * Form values change callback.
+ */
+ onChange?: (values: FormValues) => void;
+
/**
* Form completion callback.
*/
- onComplete?: (values: Record) => void;
+ onComplete?: (values: FormValues) => void;
/**
* Callback triggered on step change.
@@ -213,6 +218,7 @@ function getFieldActiveErrorFieldElement(
function FormSteps({
steps = [],
+ onChange = () => {},
onComplete = () => {},
onStepChange = () => {},
onStepSubmit = () => {},
@@ -265,6 +271,7 @@ function FormSteps({
useStepTitle(step, titleFormat);
useDidUpdateEffect(() => onStepChange(stepName!), [step]);
useDidUpdateEffect(onPageTransition, [step]);
+ useDidUpdateEffect(() => onChange(values), [values]);
useEffect(() => {
// Treat explicit initial step the same as step transition, placing focus to header.
@@ -311,6 +318,8 @@ function FormSteps({
return null;
}
+ const setPatchValues = (patch: Partial) =>
+ setValues((prevValues) => ({ ...prevValues, ...patch }));
const unknownFieldErrors = activeErrors.filter(
({ field }) => !field || !fields.current[field]?.element,
);
@@ -342,7 +351,10 @@ function FormSteps({
if (submit) {
try {
setIsSubmitting(true);
- await submit(values);
+ const patchValues = await submit(values);
+ if (patchValues) {
+ setPatchValues(patchValues);
+ }
setIsSubmitting(false);
} catch (error) {
setActiveErrors([{ error }]);
@@ -389,7 +401,7 @@ function FormSteps({
setActiveErrors((prevActiveErrors) =>
prevActiveErrors.filter(({ field }) => !field || !(field in nextValuesPatch)),
);
- setValues((prevValues) => ({ ...prevValues, ...nextValuesPatch }));
+ setPatchValues(nextValuesPatch);
})}
onError={ifStillMounted((error, { field } = {}) => {
if (field) {
diff --git a/app/javascript/packages/secret-session-storage/index.spec.ts b/app/javascript/packages/secret-session-storage/index.spec.ts
index 43093f0c664..007746288e2 100644
--- a/app/javascript/packages/secret-session-storage/index.spec.ts
+++ b/app/javascript/packages/secret-session-storage/index.spec.ts
@@ -29,53 +29,113 @@ describe('SecretSessionStorage', () => {
sandbox.restore();
});
- it('writes to session storage', async () => {
- sandbox.spy(Storage.prototype, 'setItem');
-
+ it('silently ignores invalid written storage', async () => {
+ sessionStorage.setItem(STORAGE_KEY, 'nonsense');
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'),
- ),
- );
+ await storage.load();
});
- it('loads from previous written storage', async () => {
- const storage1 = createStorage();
- await storage1.setItem('foo', 'bar');
-
- const storage2 = createStorage();
- await storage2.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');
+ });
+ });
- 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');
+ });
});
- it('returns undefined for value not yet loaded from storage', async () => {
- const storage1 = createStorage();
- await storage1.setItem('foo', 'bar');
+ describe('#getItem', () => {
+ it('returns the value from web storage', async () => {
+ const storage1 = createStorage();
+ await storage1.setItem('foo', 'bar');
- const storage2 = createStorage();
+ const storage2 = createStorage();
+ await storage2.load();
- expect(storage2.getItem('foo')).to.be.undefined();
- });
+ 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();
- it('returns undefined for value not in loaded storage', async () => {
- const storage1 = createStorage();
- await storage1.setItem('foo', 'bar');
+ expect(storage2.getItem('foo')).to.be.undefined();
+ });
- const storage2 = createStorage();
- await storage2.load();
+ it('returns undefined for value not in loaded storage', async () => {
+ const storage1 = createStorage();
+ await storage1.setItem('foo', 'bar');
- expect(storage2.getItem('baz')).to.be.undefined();
+ const storage2 = createStorage();
+ await storage2.load();
+
+ expect(storage2.getItem('baz')).to.be.undefined();
+ });
});
- it('silently ignores invalid written storage', async () => {
- sessionStorage.setItem(STORAGE_KEY, 'nonsense');
- const storage = createStorage();
- await storage.load();
+ 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({});
+ });
});
});
diff --git a/app/javascript/packages/secret-session-storage/index.ts b/app/javascript/packages/secret-session-storage/index.ts
index 453f7a34ff7..9ffce17f7a1 100644
--- a/app/javascript/packages/secret-session-storage/index.ts
+++ b/app/javascript/packages/secret-session-storage/index.ts
@@ -60,6 +60,16 @@ class SecretSessionStorage> {
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.
*
@@ -69,6 +79,13 @@ class SecretSessionStorage> {
return this.storage[key];
}
+ /**
+ * Returns values from in-memory storage.
+ */
+ getItems() {
+ return this.storage;
+ }
+
/**
* Reads and decrypts storage object, if available.
*/
diff --git a/app/javascript/packages/verify-flow/context/secrets-context.tsx b/app/javascript/packages/verify-flow/context/secrets-context.tsx
index aeae06ca481..4406ed53559 100644
--- a/app/javascript/packages/verify-flow/context/secrets-context.tsx
+++ b/app/javascript/packages/verify-flow/context/secrets-context.tsx
@@ -1,18 +1,17 @@
-import { createContext, useContext, useEffect, useCallback, useMemo, useState } from 'react';
-import type { ReactNode } from 'react';
+import { createContext, useContext, useEffect, useState } from 'react';
+import type { ReactNode, Dispatch } from 'react';
import SecretSessionStorage from '@18f/identity-secret-session-storage';
-import { useIfStillMounted } from '@18f/identity-react-hooks';
-import { VerifyFlowValues } from '../verify-flow';
+import type { VerifyFlowValues } from '../verify-flow';
-type SecretValues = Partial;
+export type SecretValues = Pick;
-type SetItem = typeof SecretSessionStorage.prototype.setItem;
+type SetItems = typeof SecretSessionStorage.prototype.setItems;
interface SecretsContextProviderProps {
/**
- * Encryption key.
+ * Secrets storage.
*/
- storeKey: Uint8Array;
+ storage: SecretSessionStorage;
/**
* Context provider children.
@@ -21,49 +20,60 @@ interface SecretsContextProviderProps {
}
/**
- * Web storage key.
+ * Minimal set of flow values to be synced to secret session storage.
*/
-const STORAGE_KEY = 'verify';
+const SYNCED_SECRET_VALUES = ['userBundleToken', 'personalKey'];
const SecretsContext = createContext({
- storage: new SecretSessionStorage(STORAGE_KEY),
- setItem: (async () => {}) as SetItem,
+ storage: new SecretSessionStorage(''),
+ setItems: (async () => {}) as SetItems,
});
-export function SecretsContextProvider({ storeKey, children }: SecretsContextProviderProps) {
- const ifStillMounted = useIfStillMounted();
- const storage = useMemo(() => new SecretSessionStorage(STORAGE_KEY), []);
- const [value, setValue] = useState({ storage, setItem: storage.setItem });
- const onChange = useCallback(() => {
- setValue({
- storage,
- async setItem(...args) {
- await storage.setItem(...args);
- onChange();
- },
- });
- }, []);
+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(() => {
- crypto.subtle
- .importKey('raw', storeKey, 'AES-GCM', true, ['encrypt', 'decrypt'])
- .then((cryptoKey) => {
- storage.key = cryptoKey;
- storage.load().then(ifStillMounted(onChange));
- });
- }, []);
+ // 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 useSecretValue(
- key: K,
-): [SecretValues[K], (nextValue: SecretValues[K]) => void] {
- const { storage, setItem } = useContext(SecretsContext);
+export function useSyncedSecretValues(
+ initialValues?: SecretValues,
+): [SecretValues, Dispatch] {
+ const { storage, setItems } = useContext(SecretsContext);
+ const [values, setValues] = useState({ ...storage.getItems(), ...initialValues });
- const setValue = (nextValue: SecretValues[K]) => setItem(key, nextValue);
+ useIdleCallbackEffect(() => {
+ const nextSecretValues: SecretValues = pick(values, SYNCED_SECRET_VALUES);
+ if (!isStorageEqual(storage.getItems(), nextSecretValues)) {
+ setItems(nextSecretValues);
+ }
+ }, [values]);
- return [storage.getItem(key), setValue];
+ return [values, setValues];
}
export default SecretsContext;
diff --git a/app/javascript/packages/verify-flow/index.ts b/app/javascript/packages/verify-flow/index.ts
index a412251b0cd..da288073876 100644
--- a/app/javascript/packages/verify-flow/index.ts
+++ b/app/javascript/packages/verify-flow/index.ts
@@ -1,4 +1,5 @@
export { SecretsContextProvider } from './context/secrets-context';
export { default as VerifyFlow } from './verify-flow';
+export type { SecretValues } from './context/secrets-context';
export type { VerifyFlowValues } from './verify-flow';
diff --git a/app/javascript/packages/verify-flow/services/api.spec.ts b/app/javascript/packages/verify-flow/services/api.spec.ts
new file mode 100644
index 00000000000..0b621dc83a1
--- /dev/null
+++ b/app/javascript/packages/verify-flow/services/api.spec.ts
@@ -0,0 +1,75 @@
+import type { SinonStub } from 'sinon';
+import { useSandbox } from '@18f/identity-test-helpers';
+import { 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(
+ '/foo/bar',
+ 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(
+ '/foo/bar',
+ 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(
+ '/foo/bar',
+ sandbox.match({
+ method: 'POST',
+ body: 'body',
+ headers: { 'X-CSRF-Token': 'csrf-value' },
+ }),
+ );
+ });
+ });
+});
diff --git a/app/javascript/packages/verify-flow/services/api.ts b/app/javascript/packages/verify-flow/services/api.ts
new file mode 100644
index 00000000000..ff9886a04e3
--- /dev/null
+++ b/app/javascript/packages/verify-flow/services/api.ts
@@ -0,0 +1,43 @@
+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 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(url, { method: 'POST', headers, body: body as BodyInit });
+
+ return options.json ? response.json() : response.text();
+}
diff --git a/app/javascript/packages/verify-flow/steps/index.ts b/app/javascript/packages/verify-flow/steps/index.ts
index c81f41d657e..fde4b7813e6 100644
--- a/app/javascript/packages/verify-flow/steps/index.ts
+++ b/app/javascript/packages/verify-flow/steps/index.ts
@@ -1,4 +1,5 @@
import personalKeyStep from './personal-key';
import personalKeyConfirmStep from './personal-key-confirm';
+import passwordConfirmStep from './password-confirm';
-export const STEPS = [personalKeyStep, personalKeyConfirmStep];
+export const STEPS = [passwordConfirmStep, personalKeyStep, personalKeyConfirmStep];
diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/index.ts b/app/javascript/packages/verify-flow/steps/password-confirm/index.ts
new file mode 100644
index 00000000000..3763448bbdb
--- /dev/null
+++ b/app/javascript/packages/verify-flow/steps/password-confirm/index.ts
@@ -0,0 +1,12 @@
+import { t } from '@18f/identity-i18n';
+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'),
+ form,
+ submit,
+} as FormStep;
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
new file mode 100644
index 00000000000..f82fc4885c8
--- /dev/null
+++ b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx
@@ -0,0 +1,25 @@
+import type { ChangeEvent } from 'react';
+import { t } from '@18f/identity-i18n';
+import { FormStepsButton } from '@18f/identity-form-steps';
+import type { FormStepComponentProps } from '@18f/identity-form-steps';
+import type { VerifyFlowValues } from '../../verify-flow';
+
+interface PasswordConfirmStepStepProps extends FormStepComponentProps {}
+
+function PasswordConfirmStep({ registerField, onChange }: PasswordConfirmStepStepProps) {
+ return (
+ <>
+ ) => {
+ onChange({ password: event.target.value });
+ }}
+ />
+
+ >
+ );
+}
+
+export default PasswordConfirmStep;
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
new file mode 100644
index 00000000000..313a7411e96
--- /dev/null
+++ b/app/javascript/packages/verify-flow/steps/password-confirm/submit.spec.ts
@@ -0,0 +1,24 @@
+import { useSandbox } from '@18f/identity-test-helpers';
+import submit, { API_ENDPOINT } from './submit';
+
+describe('submit', () => {
+ const sandbox = useSandbox();
+
+ beforeEach(() => {
+ sandbox
+ .stub(window, 'fetch')
+ .withArgs(
+ API_ENDPOINT,
+ sandbox.match({ body: JSON.stringify({ user_bundle_token: '..', password: 'hunter2' }) }),
+ )
+ .resolves({
+ json: () => Promise.resolve({ personal_key: '0000-0000-0000-0000' }),
+ } as Response);
+ });
+
+ it('sends with password confirmation values', async () => {
+ const patch = await submit({ userBundleToken: '..', password: 'hunter2' });
+
+ expect(patch).to.deep.equal({ personalKey: '0000-0000-0000-0000' });
+ });
+});
diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/submit.ts b/app/javascript/packages/verify-flow/steps/password-confirm/submit.ts
new file mode 100644
index 00000000000..870b13a1cd2
--- /dev/null
+++ b/app/javascript/packages/verify-flow/steps/password-confirm/submit.ts
@@ -0,0 +1,26 @@
+import { post } from '../../services/api';
+import type { VerifyFlowValues } from '../../verify-flow';
+
+/**
+ * API endpoint for password confirmation submission.
+ */
+export const API_ENDPOINT = '/api/verify/v2/password_confirm';
+
+/**
+ * API response shape.
+ */
+interface PasswordConfirmResponse {
+ personal_key: string;
+}
+
+async function submit({ userBundleToken, password }: VerifyFlowValues) {
+ const payload = { user_bundle_token: userBundleToken, password };
+ const json = await post(API_ENDPOINT, payload, {
+ json: true,
+ csrf: true,
+ });
+
+ return { personalKey: json.personal_key };
+}
+
+export default submit;
diff --git a/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx b/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx
index 8b134f51db5..16bf41696ff 100644
--- a/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx
+++ b/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx
@@ -18,6 +18,7 @@ type VerifyFlowStepIndicatorStep =
* Mapping of flow form steps to corresponding step indicator step.
*/
const FLOW_STEP_STEP_MAPPING: Record = {
+ password_confirm: 'secure_account',
personal_key: 'secure_account',
personal_key_confirm: 'secure_account',
};
diff --git a/app/javascript/packages/verify-flow/verify-flow.spec.tsx b/app/javascript/packages/verify-flow/verify-flow.spec.tsx
index 423112dadb1..1a567694644 100644
--- a/app/javascript/packages/verify-flow/verify-flow.spec.tsx
+++ b/app/javascript/packages/verify-flow/verify-flow.spec.tsx
@@ -10,6 +10,9 @@ describe('VerifyFlow', () => {
beforeEach(() => {
sandbox.spy(analytics, 'trackEvent');
+ sandbox.stub(window, 'fetch').resolves({
+ json: () => Promise.resolve({ personal_key: personalKey }),
+ } as Response);
});
afterEach(() => {
@@ -19,33 +22,28 @@ describe('VerifyFlow', () => {
it('advances through flow to completion', async () => {
const onComplete = sinon.spy();
- const { getByText, getByLabelText } = render(
+ const { getByText, findByText, getByLabelText } = render(
,
);
+ // Password confirm
+ expect(analytics.trackEvent).to.have.been.calledWith('IdV: password confirm visited');
+ await userEvent.type(getByLabelText('idv.form.password'), 'password');
+ await userEvent.click(getByText('forms.buttons.continue'));
+ expect(analytics.trackEvent).to.have.been.calledWith('IdV: password confirm submitted');
+
// Personal key
- expect(getByText('idv.messages.confirm')).to.be.ok();
+ await findByText('idv.messages.confirm');
+ expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key visited');
await userEvent.click(getByText('forms.buttons.continue'));
+ expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key submitted');
// Personal key confirm
+ expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key confirm visited');
expect(getByText('idv.messages.confirm')).to.be.ok();
await userEvent.type(getByLabelText('forms.personal_key.confirmation_label'), personalKey);
await userEvent.keyboard('{Enter}');
expect(onComplete).to.have.been.called();
});
-
- it('calls trackEvents for personal key steps', async () => {
- const { getByLabelText, getByText, getAllByText } = render(
- {}} />,
- );
- expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key visited');
-
- await userEvent.click(getByText('forms.buttons.continue'));
- expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key confirm visited');
- await userEvent.type(getByLabelText('forms.personal_key.confirmation_label'), personalKey);
- await userEvent.click(getAllByText('forms.buttons.continue')[1]);
-
- expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key submitted');
- });
});
diff --git a/app/javascript/packages/verify-flow/verify-flow.tsx b/app/javascript/packages/verify-flow/verify-flow.tsx
index f4097973926..a7cf7b2945c 100644
--- a/app/javascript/packages/verify-flow/verify-flow.tsx
+++ b/app/javascript/packages/verify-flow/verify-flow.tsx
@@ -4,6 +4,7 @@ import { trackEvent } from '@18f/identity-analytics';
import { STEPS } from './steps';
import VerifyFlowStepIndicator from './verify-flow-step-indicator';
import VerifyFlowAlert from './verify-flow-alert';
+import { useSyncedSecretValues } from './context/secrets-context';
export interface VerifyFlowValues {
userBundleToken?: string;
@@ -29,6 +30,8 @@ export interface VerifyFlowValues {
phone?: string;
ssn?: string;
+
+ password?: string;
}
interface VerifyFlowProps {
@@ -86,6 +89,7 @@ function VerifyFlow({
appName,
onComplete,
}: VerifyFlowProps) {
+ const [syncedValues, setSyncedValues] = useSyncedSecretValues(initialValues);
const [currentStep, setCurrentStep] = useState(STEPS[0].name);
useEffect(() => {
logStepVisited(currentStep);
@@ -102,10 +106,11 @@ function VerifyFlow({
string.replace(/[^a-z]([a-z])/gi, (_match, nextLetter) => nextLetter.toUpperCase());
-if (initialValues.userBundleToken) {
- const jwtData = JSON.parse(atob(initialValues.userBundleToken.split('.')[1]));
- const pii = Object.fromEntries(
- Object.entries(jwtData.pii).map(([key, value]) => [camelCase(key), value]),
- );
- Object.assign(initialValues, pii);
-}
+const mapKeys = (object: object, mapKey: (key: string) => string) =>
+ Object.entries(object).map(([key, value]) => [mapKey(key), value]);
function onComplete() {
window.location.href = completionURL;
}
-render(
-
-
- ,
- appRoot,
-);
+const storage = new SecretSessionStorage('verify');
+
+(async () => {
+ const cryptoKey = await crypto.subtle.importKey('raw', storeKey, 'AES-GCM', true, [
+ 'encrypt',
+ 'decrypt',
+ ]);
+ storage.key = cryptoKey;
+ await storage.load();
+ if (initialValues.userBundleToken) {
+ await storage.setItem('userBundleToken', initialValues.userBundleToken);
+ }
+
+ const userBundleToken = storage.getItem('userBundleToken');
+ if (userBundleToken) {
+ const jwtData = JSON.parse(atob(userBundleToken.split('.')[1]));
+ const pii = Object.fromEntries(mapKeys(jwtData.pii, camelCase));
+ Object.assign(initialValues, pii);
+ }
+
+ render(
+
+
+ ,
+ appRoot,
+ );
+})();
diff --git a/config/routes.rb b/config/routes.rb
index 05652b03c88..e68a712297e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -327,7 +327,7 @@
get '/verify/v2(/:step)' => 'verify#show', as: :idv_app
namespace :api do
- post '/verify/complete' => 'verify/complete#create'
+ post '/verify/v2/password_confirm' => 'verify/password_confirm#create'
end
get '/account/verify' => 'idv/gpo_verify#index', as: :idv_gpo_verify
diff --git a/spec/controllers/api/verify/complete_controller_spec.rb b/spec/controllers/api/verify/password_confirm_controller_spec.rb
similarity index 98%
rename from spec/controllers/api/verify/complete_controller_spec.rb
rename to spec/controllers/api/verify/password_confirm_controller_spec.rb
index 4d5cecbc6e8..431b5638bb8 100644
--- a/spec/controllers/api/verify/complete_controller_spec.rb
+++ b/spec/controllers/api/verify/password_confirm_controller_spec.rb
@@ -1,6 +1,6 @@
require 'rails_helper'
-describe Api::Verify::CompleteController do
+describe Api::Verify::PasswordConfirmController do
include PersonalKeyValidator
include SamlAuthHelper
diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb
index cd866e877b7..8a193a4d40a 100644
--- a/spec/controllers/idv/review_controller_spec.rb
+++ b/spec/controllers/idv/review_controller_spec.rb
@@ -219,6 +219,19 @@ def show
hash_including(name: :verify_phone_or_address, status: :pending),
)
end
+
+ context 'idv app password confirm step is enabled' do
+ before do
+ allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).
+ and_return(['password_confirm'])
+ end
+
+ it 'redirects to idv app' do
+ get :new
+
+ expect(response).to redirect_to idv_app_path
+ end
+ end
end
context 'user chooses address verification' do
diff --git a/spec/support/features/idv_step_helper.rb b/spec/support/features/idv_step_helper.rb
index 49f0063e344..c0a77c3e2f3 100644
--- a/spec/support/features/idv_step_helper.rb
+++ b/spec/support/features/idv_step_helper.rb
@@ -47,7 +47,7 @@ def complete_idv_steps_with_phone_before_confirmation_step(user = user_with_2fa)
complete_idv_steps_with_phone_before_review_step(user)
password = user.password || user_password
fill_in 'Password', with: password
- click_continue
+ click_idv_continue
end
alias complete_idv_steps_before_review_step complete_idv_steps_with_phone_before_review_step