Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module Api
module Verify
class CompleteController < Api::BaseController
class PasswordConfirmController < Api::BaseController
Comment thread
aduth marked this conversation as resolved.
def create
result, personal_key = Api::ProfileCreationForm.new(
password: verify_params[:password],
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/idv/review_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
25 changes: 25 additions & 0 deletions app/javascript/packages/form-steps/form-steps.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FormSteps steps={steps} />);

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 = [
Expand Down Expand Up @@ -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(<FormSteps steps={STEPS} onChange={onChange} />);

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(
Expand Down
20 changes: 16 additions & 4 deletions app/javascript/packages/form-steps/form-steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export interface FormStep<V extends FormValues = {}> {
/**
* Optionally-asynchronous submission behavior, expected to throw any submission error.
*/
submit?: (values: V) => void | Promise<void>;
submit?: (values: V) => void | Record<string, any> | Promise<void | Record<string, any>>;

/**
* Human-readable step label.
Expand Down Expand Up @@ -134,10 +134,15 @@ interface FormStepsProps {
*/
autoFocus?: boolean;

/**
* Form values change callback.
*/
onChange?: (values: FormValues) => void;

/**
* Form completion callback.
*/
onComplete?: (values: Record<string, any>) => void;
onComplete?: (values: FormValues) => void;

/**
* Callback triggered on step change.
Expand Down Expand Up @@ -213,6 +218,7 @@ function getFieldActiveErrorFieldElement(

function FormSteps({
steps = [],
onChange = () => {},
onComplete = () => {},
onStepChange = () => {},
onStepSubmit = () => {},
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -311,6 +318,8 @@ function FormSteps({
return null;
}

const setPatchValues = (patch: Partial<FormValues>) =>
setValues((prevValues) => ({ ...prevValues, ...patch }));
const unknownFieldErrors = activeErrors.filter(
({ field }) => !field || !fields.current[field]?.element,
);
Expand Down Expand Up @@ -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 }]);
Expand Down Expand Up @@ -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) {
Expand Down
130 changes: 95 additions & 35 deletions app/javascript/packages/secret-session-storage/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});
});
});
17 changes: 17 additions & 0 deletions app/javascript/packages/secret-session-storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ class SecretSessionStorage<S extends Record<string, JSONValue>> {
await this.#writeStorage();
}

/**
* Sets a patch of values into storage.
*
* @param values Storage object values.
*/
async setItems(values: Partial<S>) {
Object.assign(this.storage, values);
await this.#writeStorage();
}

/**
* Gets a value from the in-memory storage.
*
Expand All @@ -69,6 +79,13 @@ class SecretSessionStorage<S extends Record<string, JSONValue>> {
return this.storage[key];
}

/**
* Returns values from in-memory storage.
*/
getItems() {
return this.storage;
Comment thread
aduth marked this conversation as resolved.
}

/**
* Reads and decrypts storage object, if available.
*/
Expand Down
Loading