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