Skip to content
Open
2 changes: 2 additions & 0 deletions src/haapi-react-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ import { ErrorBoundary } from './shared/feature/error-handling/ErrorBoundary';
import { HaapiStepperStepUI } from '@curity/haapi-react-sdk/haapi-stepper/feature/steps/HaapiStepperStepUI';
import { HaapiStepper } from '@curity/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepper';
import { HaapiStepperErrorNotifier } from '@curity/haapi-react-sdk/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier';
import { HaapiStepperHistoryNavigation } from './shared/feature/history/HaapiStepperHistoryNavigation';

export function App() {
return (
<HaapiAppConfigProvider>
<ErrorBoundary>
<HaapiStepper>
<HaapiStepperHistoryNavigation />
<Layout>
<HaapiStepperErrorNotifier>
<HaapiStepperStepUI />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright (C) 2026 Curity AB. All rights reserved.
*
* The contents of this file are the property of Curity AB.
* You may not copy or use this file, in either source code
* or executable form, except in compliance with terms
* set by Curity AB.
*
* For further information, please contact Curity AB.
*/

import { useHaapiStepperHistoryNavigation } from './useHaapiStepperHistoryNavigation';

/**
* Headless component that wires the browser's back/forward buttons to the HAAPI stepper history.
* Must be rendered inside `<HaapiStepper>` so it can access the stepper via `useHaapiStepper`.
*/
export function HaapiStepperHistoryNavigation() {
useHaapiStepperHistoryNavigation();
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright (C) 2026 Curity AB. All rights reserved.
*
* The contents of this file are the property of Curity AB.
* You may not copy or use this file, in either source code
* or executable form, except in compliance with terms
* set by Curity AB.
*
* For further information, please contact Curity AB.
*/

import { describe, expect, it } from 'vitest';
import { HAAPI_ACTION_TYPES } from '@curity/haapi-react-sdk/haapi-stepper/data-access/types/haapi-action.types';
import { HTTP_METHODS } from '@curity/haapi-react-sdk/haapi-stepper/data-access/types/haapi-form.types';
import {
HAAPI_STEPPER_ELEMENT_TYPES,
HAAPI_STEPS,
} from '@curity/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types';
import type {
HaapiStepperClientOperationAction,
HaapiStepperFormAction,
HaapiStepperHistoryEntry,
HaapiStepperLink,
HaapiStepperNextStepAction,
} from '@curity/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types';
import { isReproducibleAction, isReproducibleHistoryEntry } from './reproducible-action';

const link = { href: '/next', type: HAAPI_STEPPER_ELEMENT_TYPES.LINK } as HaapiStepperLink;
const formWith = (method: HTTP_METHODS) =>
({ template: HAAPI_ACTION_TYPES.FORM, model: { method } }) as HaapiStepperFormAction;
const clientOperation = { template: HAAPI_ACTION_TYPES.CLIENT_OPERATION } as HaapiStepperClientOperationAction;

describe('isReproducibleAction', () => {
it('treats links as reproducible (always GET)', () => {
expect(isReproducibleAction(link)).toBe(true);
});

it('treats GET form actions as reproducible', () => {
expect(isReproducibleAction(formWith(HTTP_METHODS.GET))).toBe(true);
});

it('treats POST form actions as not reproducible', () => {
expect(isReproducibleAction(formWith(HTTP_METHODS.POST))).toBe(false);
});

it('treats client operations as not reproducible', () => {
expect(isReproducibleAction(clientOperation)).toBe(false);
});
});

const entryFor = (action: HaapiStepperNextStepAction, stepType?: HAAPI_STEPS): HaapiStepperHistoryEntry =>
({ step: { type: stepType }, triggeredByAction: action, timestamp: new Date() }) as HaapiStepperHistoryEntry;

describe('isReproducibleHistoryEntry', () => {
it('is reproducible when the entry was produced by a reproducible action', () => {
expect(isReproducibleHistoryEntry(entryFor(link))).toBe(true);
expect(isReproducibleHistoryEntry(entryFor(formWith(HTTP_METHODS.GET)))).toBe(true);
});

it('is not reproducible when the entry was produced by a non-reproducible action', () => {
expect(isReproducibleHistoryEntry(entryFor(formWith(HTTP_METHODS.POST)))).toBe(false);
expect(isReproducibleHistoryEntry(entryFor(clientOperation))).toBe(false);
});

it('is not reproducible for a polling step even though its poll action is a GET form', () => {
expect(isReproducibleHistoryEntry(entryFor(formWith(HTTP_METHODS.GET), HAAPI_STEPS.POLLING))).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright (C) 2026 Curity AB. All rights reserved.
*
* The contents of this file are the property of Curity AB.
* You may not copy or use this file, in either source code
* or executable form, except in compliance with terms
* set by Curity AB.
*
* For further information, please contact Curity AB.
*/

import { HAAPI_ACTION_TYPES } from '@curity/haapi-react-sdk/haapi-stepper/data-access/types/haapi-action.types';
import { HTTP_METHODS } from '@curity/haapi-react-sdk/haapi-stepper/data-access/types/haapi-form.types';
import { HAAPI_STEPS } from '@curity/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types';
import type {
HaapiStepperHistoryEntry,
HaapiStepperNextStepAction,
HaapiStepperNextStepPayload,
} from '@curity/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types';

/** A reproducible step: the action (and payload) that re-opens it when navigating back/forward. */
export interface ReproducibleStep {
action: HaapiStepperNextStepAction;
payload?: HaapiStepperNextStepPayload;
}

/**
* Whether an action can be safely re-issued to reconstruct the step it produced when navigating the
* history — i.e. whether the step it produced is a reproducible back/forward target:
* - **Links** are always GET → reproducible.
* - **Form actions** are reproducible only when their method is GET (a POST may have mutated backend
* state or consumed a one-time token).
* - **Client operations** (BankID, WebAuthn, external-browser-flow) resolve to single-use POST
* continue-actions → never reproducible.
*
* Steps produced by non-reproducible actions are still recorded, but are skipped when navigating back/forward.
*/
export function isReproducibleAction(action: HaapiStepperNextStepAction): boolean {
if ('href' in action) {
return true;
}

if (action.template === HAAPI_ACTION_TYPES.FORM) {
return action.model.method === HTTP_METHODS.GET;
Comment thread
aleixsuau marked this conversation as resolved.
}

return false;
}

/**
* Whether the step recorded in a history entry can be safely re-opened — i.e. whether the action that
* produced it is reproducible (see {@link isReproducibleAction}).
*/
export function isReproducibleHistoryEntry(entry: HaapiStepperHistoryEntry): boolean {
if (entry.step.type === HAAPI_STEPS.POLLING) {
return false;
}

return isReproducibleAction(entry.triggeredByAction);
}
Loading
Loading