From 5594a77adb09997464bd2abe8d875773b3079483 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Wed, 27 May 2026 15:17:10 +0200 Subject: [PATCH 1/6] IS-11380 HaapiStepper: own bootstrap config and the haapiFetch lifecycle. - Add bootstrap: BootstrapConfiguration to HaapiStepperConfig (single field, defaults to the module-level configuration singleton). Override via . - Introduce useHaapiFetch(haapi) hook in data-access/. Owns the haapiFetch lifecycle via a module-level single-slot cache (driver is a process-global, one active fetcher per page). Returns a bound sendHaapiFetchRequest. - sendHaapiFetchRequest(action, haapiFetch: FetchLike) becomes pure. haapi-fetch-initializer.ts removed. - Move HaapiStepperConfig to haapi-stepper.types.ts (CONFIG TYPINGS). - Refactor processHaapiNextStep to a single named-params object. - Re-export useHaapiFetch from data-access/index.ts. - Tests: new useHaapiFetch.spec.ts (binding to config, link routing, form routing); new bootstrap-override case in HaapiStepper.spec.tsx. --- .../data-access/haapi-fetch-initializer.ts | 16 ---- .../data-access/happi-fetch-request.ts | 4 +- .../src/haapi-stepper/data-access/index.ts | 1 + .../data-access/useHaapiFetch.spec.ts | 93 +++++++++++++++++++ .../data-access/useHaapiFetch.ts | 37 ++++++++ .../feature/stepper/HaapiStepper.spec.tsx | 24 ++++- .../feature/stepper/HaapiStepper.tsx | 85 ++++++++--------- .../stepper/data-formatters/polling-step.ts | 2 +- .../feature/stepper/haapi-stepper.types.ts | 12 +++ .../authentication-or-registration-step.ts | 3 +- 10 files changed, 209 insertions(+), 68 deletions(-) delete mode 100644 src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-initializer.ts create mode 100644 src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.spec.ts create mode 100644 src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.ts diff --git a/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-initializer.ts b/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-initializer.ts deleted file mode 100644 index 5c188186..00000000 --- a/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-initializer.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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 { createHaapiFetch } from '@curity/identityserver-haapi-web-driver'; -import { configuration } from './bootstrap-configuration'; - -const haapiFetch = createHaapiFetch(configuration.haapi); -export default haapiFetch; diff --git a/src/login-web-app/src/haapi-stepper/data-access/happi-fetch-request.ts b/src/login-web-app/src/haapi-stepper/data-access/happi-fetch-request.ts index e78885fa..fc79e80c 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/happi-fetch-request.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/happi-fetch-request.ts @@ -9,13 +9,13 @@ * For further information, please contact Curity AB. */ +import { FetchLike } from '@curity/identityserver-haapi-web-driver'; import { MEDIA_TYPES } from '../../shared/util/types/media.types'; -import haapiFetch from './haapi-fetch-initializer'; import { createApiRequest } from './haapi-fetch-utils'; import { HaapiFetchAction } from './types/haapi-fetch.types'; import { HaapiStep } from './types/haapi-step.types'; -export async function sendHaapiFetchRequest(action: HaapiFetchAction): Promise { +export async function sendHaapiFetchRequest(action: HaapiFetchAction, haapiFetch: FetchLike): Promise { const request = createApiRequest(action); const response = await haapiFetch(request.url, request.init); const mediaType = response.headers.get('Content-Type'); diff --git a/src/login-web-app/src/haapi-stepper/data-access/index.ts b/src/login-web-app/src/haapi-stepper/data-access/index.ts index 6a047277..7607cd14 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/index.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/index.ts @@ -12,3 +12,4 @@ export * from './haapi-fetch-utils'; export * from './happi-fetch-request'; export * from './types'; +export * from './useHaapiFetch'; diff --git a/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.spec.ts b/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.spec.ts new file mode 100644 index 00000000..fa6b1241 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.spec.ts @@ -0,0 +1,93 @@ +/* + * 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 { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import type { HaapiConfiguration } from '@curity/identityserver-haapi-web-driver'; +import { MEDIA_TYPES } from '../../shared/util/types/media.types'; +import { useHaapiFetch } from './useHaapiFetch'; +import { HAAPI_STEPS, type HaapiLink } from './types/haapi-step.types'; +import { HAAPI_ACTION_TYPES, type HaapiFormAction } from './types/haapi-action.types'; +import { HAAPI_FORM_FIELDS, HTTP_METHODS } from './types/haapi-form.types'; + +// Hoist the spies so the vi.mock factory (which runs at module-load time, before +// the test file body executes) can reference them without hitting the TDZ. +const { mockHaapiFetch, createHaapiFetchSpy } = vi.hoisted(() => { + const mockHaapiFetch = vi.fn(); + const createHaapiFetchSpy = vi.fn(() => mockHaapiFetch); + return { mockHaapiFetch, createHaapiFetchSpy }; +}); +vi.mock('@curity/identityserver-haapi-web-driver', () => ({ + createHaapiFetch: createHaapiFetchSpy, +})); + +describe('useHaapiFetch', () => { + const haapiConfig = { clientId: 'test-client', tokenEndpoint: 'https://example/token' } as HaapiConfiguration; + + beforeEach(() => { + mockHaapiFetch.mockReset(); + }); + + it('builds the fetcher via createHaapiFetch with the supplied HaapiConfiguration', () => { + renderHook(() => useHaapiFetch(haapiConfig)); + + expect(createHaapiFetchSpy).toHaveBeenCalledWith(haapiConfig); + }); + + it('sendHaapiFetchRequest forwards link actions to the underlying haapiFetch as a GET to the link href', async () => { + mockHaapiFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ type: HAAPI_STEPS.AUTHENTICATION }), { + headers: { 'Content-Type': MEDIA_TYPES.AUTH }, + }) + ); + + const { result } = renderHook(() => useHaapiFetch(haapiConfig)); + + const link: HaapiLink = { href: '/test/href', rel: 'self' }; + await result.current.sendHaapiFetchRequest(link); + + expect(mockHaapiFetch).toHaveBeenCalledWith(link.href, { method: 'GET' }); + }); + + it('sendHaapiFetchRequest forwards form actions to the underlying haapiFetch with the payload encoded into the request body', async () => { + mockHaapiFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ type: HAAPI_STEPS.AUTHENTICATION }), { + headers: { 'Content-Type': MEDIA_TYPES.AUTH }, + }) + ); + + const { result } = renderHook(() => useHaapiFetch(haapiConfig)); + + const formAction: HaapiFormAction = { + template: HAAPI_ACTION_TYPES.FORM, + kind: 'login', + model: { + method: HTTP_METHODS.POST, + href: '/api/login', + type: MEDIA_TYPES.FORM_URLENCODED, + fields: [{ name: 'username', type: HAAPI_FORM_FIELDS.USERNAME }], + }, + }; + + await result.current.sendHaapiFetchRequest({ + action: formAction, + payload: { username: 'alice' }, + }); + + expect(mockHaapiFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockHaapiFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(formAction.model.href); + expect(init.method).toBe(formAction.model.method); + expect(init.headers).toEqual({ 'Content-Type': formAction.model.type }); + expect(init.body).toBeInstanceOf(URLSearchParams); + expect((init.body as URLSearchParams).get('username')).toBe('alice'); + }); +}); diff --git a/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.ts b/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.ts new file mode 100644 index 00000000..95e228ce --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.ts @@ -0,0 +1,37 @@ +/* + * 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 { useMemo } from 'react'; +import { createHaapiFetch } from '@curity/identityserver-haapi-web-driver'; +import type { FetchLike, HaapiConfiguration } from '@curity/identityserver-haapi-web-driver'; +import { sendHaapiFetchRequest } from './happi-fetch-request'; +import type { HaapiFetchAction } from './types/haapi-fetch.types'; + +// The @curity/identityserver-haapi-web-driver is a *process-global singleton*: +// the docs state "at most one active fetch-like function", and in practice +// `createHaapiFetch` registers an iframe + postMessage channel that can't +// survive rapid create/close/create cycles (e.g. React StrictMode dev). +// Caching the created fetch function allows us to reuse it and avoid such issues. +let cachedHaapiFetch: FetchLike | undefined; + +function getHaapiFetch(haapi: HaapiConfiguration): FetchLike { + return (cachedHaapiFetch ??= createHaapiFetch(haapi)); +} + +export function useHaapiFetch(haapi: HaapiConfiguration) { + const haapiFetch = useMemo(() => getHaapiFetch(haapi), [haapi]); + return useMemo( + () => ({ + sendHaapiFetchRequest: (action: HaapiFetchAction) => sendHaapiFetchRequest(action, haapiFetch), + }), + [haapiFetch] + ); +} diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index 4a9f651c..b0b1a235 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -82,6 +82,23 @@ describe('HaapiStepper', () => { expect(screen.queryByTestId('error')).not.toBeInTheDocument(); }); + it('should use the bootstrap config provided via the stepper config prop instead of the default', async () => { + const overrideUrl = 'https://override.example/start'; + + render( + + + + ); + + expect(mockHaapiFetch).toHaveBeenCalledWith(overrideUrl, { method: 'GET' }); + + const stepRendered = await screen.findByTestId('step-type'); + expect(stepRendered).toHaveTextContent(initialStepType); + }); + it('should go to the next step and provide the updated current step', async () => { render( @@ -1208,16 +1225,17 @@ describe('HaapiStepper', () => { }); }); -const mockHaapiFetch = vi.hoisted(() => vi.fn()); -vi.mock('../../data-access/haapi-fetch-initializer', () => { +const mockHaapiFetch = vi.fn(); +vi.mock('@curity/identityserver-haapi-web-driver', () => { return { - default: mockHaapiFetch, + createHaapiFetch: () => mockHaapiFetch, }; }); const mockConfiguration: Partial = vi.hoisted(() => { return { initialUrl: 'https://example.com/auth', + haapi: {} as BootstrapConfiguration['haapi'], }; }); vi.mock('../../data-access/bootstrap-configuration', () => { diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx index 555cf943..b7107559 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx @@ -11,6 +11,8 @@ import { ReactNode, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { HaapiClientOperationAction, HaapiFormAction } from '../../data-access/types/haapi-action.types'; +import type { HaapiFetchAction } from '../../data-access/types/haapi-fetch.types'; +import { useHaapiFetch } from '../../data-access/useHaapiFetch'; import { HAAPI_PROBLEM_STEPS, HAAPI_STEPPER_ELEMENT_TYPES, @@ -30,6 +32,7 @@ import { sendHaapiFetchRequest } from '../../data-access/happi-fetch-request'; import { configuration } from '../../data-access/bootstrap-configuration'; import type { HaapiStepperClientOperationAction, + HaapiStepperConfig, HaapiStepperError, HaapiStepperFormAction, HaapiStepperHistoryEntry, @@ -45,19 +48,13 @@ import { useRefCallback } from '../../util/useRefCallBack'; import { handleAuthenticationOrRegistrationStep } from './step-handlers/authentication-or-registration-step'; const DEFAULT_CONFIG: Required = { + bootstrap: configuration, pollingInterval: 3000, bankIdAutostart: true, autoRedirectOnAuthenticationComplete: true, webAuthnAutostart: true, }; -export interface HaapiStepperConfig { - pollingInterval: number; - bankIdAutostart: boolean; - autoRedirectOnAuthenticationComplete: boolean; - webAuthnAutostart: boolean; -} - interface HaapiStepperProps { children: ReactNode; config?: Partial; @@ -277,6 +274,7 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { const throwErrorToAppErrorBoundary = useThrowErrorToAppErrorBoundary(); const pendingOperation = useRef(null); const configResult = useMemo(() => ({ ...DEFAULT_CONFIG, ...config }), [config]); + const { sendHaapiFetchRequest } = useHaapiFetch(configResult.bootstrap.haapi); const setCurrentStepAndUpdateHistory = useCallback( (newStep, triggeredByAction, triggeredByPayload) => { @@ -300,15 +298,16 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { setError(null); cancelPendingOperation(pendingOperation); - const { nextStepData, nextStepError } = await processHaapiNextStep( + const { nextStepData, nextStepError } = await processHaapiNextStep({ currentStep, + nextStep, + history, action, payload, pendingOperation, - nextStep, - configResult, - history - ); + config: configResult, + sendHaapiFetchRequest, + }); if (nextStepError) { setError(nextStepError); @@ -322,7 +321,7 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { setCurrentStepAndUpdateHistory(nextStepData, action, payload); }, // eslint-disable-next-line react-hooks/exhaustive-deps -- nextStep is a stable ref via useRefCallback, defined below - [configResult, currentStep, history, setCurrentStepAndUpdateHistory] + [configResult, sendHaapiFetchRequest, currentStep, history, setCurrentStepAndUpdateHistory] ); const nextStepImplementation: HaapiStepperNextStep = useCallback( @@ -340,9 +339,9 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { const nextStep = useRefCallback(nextStepImplementation); useEffect(() => { - nextStep(getInitialStepLink()); + nextStep(getInitialStepLink(configResult.bootstrap.initialUrl)); return () => cancelPendingOperation(pendingOperation); - }, [nextStep]); + }, [nextStep, configResult.bootstrap.initialUrl]); const contextValue = useMemo( () => ({ currentStep, loading, error, nextStep, history }), @@ -352,24 +351,29 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { return {children}; } -async function processHaapiNextStep( - currentStep: HaapiStep | null, +interface ProcessHaapiNextStepParams { + currentStep: HaapiStep | null; + nextStep: HaapiStepperNextStep; + history: HaapiStepperHistoryEntry[]; action: | HaapiFormAction | HaapiClientOperationAction | HaapiLink | HaapiStepperFormAction | HaapiStepperClientOperationAction - | HaapiStepperLink, - payload: HaapiStepperNextStepPayload | undefined, - pendingOperation: RefObject, - nextStep: HaapiStepperNextStep, - config: HaapiStepperConfig, - history: HaapiStepperHistoryEntry[] -): Promise<{ + | HaapiStepperLink; + payload: HaapiStepperNextStepPayload | undefined; + pendingOperation: RefObject; + config: HaapiStepperConfig; + sendHaapiFetchRequest: (action: HaapiFetchAction) => Promise; +} + +async function processHaapiNextStep(params: ProcessHaapiNextStepParams): Promise<{ nextStepData?: HaapiStepperStep; nextStepError?: HaapiStepperError; }> { + const { currentStep, nextStep, history, action, payload, pendingOperation, config, sendHaapiFetchRequest } = params; + if (isClientOperation(action)) { const { clientOperationData, clientOperationError } = await performClientOperation( action, @@ -381,15 +385,11 @@ async function processHaapiNextStep( return { nextStepError: clientOperationError }; } - return processHaapiNextStep( - currentStep, - clientOperationData.action, - clientOperationData.payload, - pendingOperation, - nextStep, - config, - history - ); + return processHaapiNextStep({ + ...params, + action: clientOperationData.action, + payload: clientOperationData.payload, + }); } const isLinkAction = 'href' in action; @@ -398,15 +398,12 @@ async function processHaapiNextStep( switch (nextStepResponse.type) { case HAAPI_STEPS.REDIRECTION: - return processHaapiNextStep( - nextStepResponse, - nextStepResponse.actions[0], - undefined, - pendingOperation, - nextStep, - config, - history - ); + return processHaapiNextStep({ + ...params, + currentStep: nextStepResponse, + action: nextStepResponse.actions[0], + payload: undefined, + }); case HAAPI_STEPS.POLLING: return handlePollingStep(nextStepResponse, pendingOperation, nextStep, config, history); @@ -451,9 +448,9 @@ function cancelPendingOperation(pendingOperation: RefObject Date: Fri, 29 May 2026 14:22:17 +0200 Subject: [PATCH 2/6] IS-11380 HaapiStepper: split app vs library bootstrap config and add standalone-mode support. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/login-web-app/src/App.tsx | 6 +- src/login-web-app/src/haapi-stepper/README.md | 42 ++++++-- .../data-access/bootstrap-configuration.ts | 31 ------ .../feature/stepper/HaapiStepper.spec.tsx | 97 ++++++++++++++++--- .../feature/stepper/HaapiStepper.tsx | 53 +++++++--- .../feature/stepper/haapi-stepper.types.ts | 15 ++- .../stepper/step-handlers/completed-step.ts | 2 +- .../shared/feature/app-config/AppConfig.tsx | 18 ---- ...gContext.tsx => HaapiAppConfigContext.tsx} | 4 +- ...AppConfigHook.ts => HaapiAppConfigHook.ts} | 18 ++-- .../app-config/HaapiAppConfigProvider.tsx | 22 +++++ .../src/shared/feature/app-config/types.ts | 10 ++ .../src/shared/ui/Layout.spec.tsx | 12 +-- src/login-web-app/src/shared/ui/Layout.tsx | 4 +- src/login-web-app/src/shared/ui/Logo.spec.tsx | 12 +-- src/login-web-app/src/shared/ui/Logo.tsx | 4 +- 16 files changed, 235 insertions(+), 115 deletions(-) delete mode 100644 src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts delete mode 100644 src/login-web-app/src/shared/feature/app-config/AppConfig.tsx rename src/login-web-app/src/shared/feature/app-config/{AppConfigContext.tsx => HaapiAppConfigContext.tsx} (65%) rename src/login-web-app/src/shared/feature/app-config/{AppConfigHook.ts => HaapiAppConfigHook.ts} (50%) create mode 100644 src/login-web-app/src/shared/feature/app-config/HaapiAppConfigProvider.tsx create mode 100644 src/login-web-app/src/shared/feature/app-config/types.ts diff --git a/src/login-web-app/src/App.tsx b/src/login-web-app/src/App.tsx index 2dbb8c6c..81162c20 100644 --- a/src/login-web-app/src/App.tsx +++ b/src/login-web-app/src/App.tsx @@ -10,7 +10,7 @@ */ import { Layout } from './shared/ui/Layout'; -import { AppConfig } from './shared/feature/app-config/AppConfig'; +import { HaapiAppConfigProvider } from './shared/feature/app-config/HaapiAppConfigProvider'; import { ErrorBoundary } from './shared/feature/error-handling/ErrorBoundary'; import { HaapiStepperStepUI } from './haapi-stepper/feature/steps/HaapiStepperStepUI'; import { HaapiStepper } from './haapi-stepper/feature/stepper/HaapiStepper'; @@ -18,7 +18,7 @@ import { HaapiStepperErrorNotifier } from './haapi-stepper/feature/stepper/Haapi export function App() { return ( - + @@ -28,6 +28,6 @@ export function App() { - + ); } diff --git a/src/login-web-app/src/haapi-stepper/README.md b/src/login-web-app/src/haapi-stepper/README.md index ff9b9c4f..98feeac2 100644 --- a/src/login-web-app/src/haapi-stepper/README.md +++ b/src/login-web-app/src/haapi-stepper/README.md @@ -78,16 +78,44 @@ const { currentStep, loading, error, nextStep } = useHaapiStepper(); ### Basic Setup +#### Bootstrap Configuration + +The `HaapiStepper` needs a **bootstrap configuration** — at minimum an `initialUrl` (where the flow starts) and a `haapi` driver config (the HAAPI web-driver settings). It supports two delivery modes, designed for two different deployment shapes: + +##### Served mode (default) + +When the `HaapiStepper` runs inside a server-rendered shell — like the Curity Login Web App — the shell injects the bootstrap configuration onto `window.__CONFIG__` *before* the SPA boots. In that case, no configuration prop is needed: + ```tsx -function App() { - return ( - - - - ); -} +// The shell has already injected window.__CONFIG__ — just mount the stepper. + + + ``` +This is the default behavior and covers the vast majority of deployments (the LWA and any other Curity-served frontend). + +##### Standalone (library) mode + +When the `HaapiStepper` is consumed as a library — e.g. embedded in a third-party app or any context that doesn't set `window.__CONFIG__` — the consumer supplies the bootstrap configuration explicitly via the `config.bootstrap` prop: + +```tsx +import { HaapiStepper } from '@curity/login-web-app/haapi-stepper'; +import type { BootstrapConfiguration } from '@curity/login-web-app/haapi-stepper'; + +const bootstrapConfig: BootstrapConfiguration = { + initialUrl: 'https://idsvr.example.com/oauth/v2/oauth-authorize/...', + haapi: { /* HAAPI web-driver config */ }, + theme: { logo: { path: '/logo.svg', isInsideWell: true } }, +}; + + + + +``` + +Both modes can be mixed with `config` overrides for other tunables (e.g. `pollingInterval`, `bankIdAutostart`); see the [`HaapiStepperConfig` type](./feature/stepper/haapi-stepper.types.ts) for the full set. + ### Usage Because `HaapiStepper` does not have a UI, it can be used to build custom flow user interfaces from scratch, or it can be used in combination with the [HaapiStepperStepUI](#haapi-ui-step) component, which provides a ready-to-use, highly customizable, HAAPI UI solution. diff --git a/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts b/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts deleted file mode 100644 index 34b64bf8..00000000 --- a/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 { HaapiConfiguration } from '@curity/identityserver-haapi-web-driver'; - -export interface BootstrapConfiguration { - initialUrl: string; - haapi: HaapiConfiguration; - theme: { - logo: { - path: string; - isInsideWell: boolean; - }; - }; -} - -// @ts-expect-error window.__CONFIG__ is not declared on the Window type -const _configuration = window.__CONFIG__ as BootstrapConfiguration | undefined; -if (!_configuration) { - throw new Error('Configuration not set'); -} - -export const configuration = _configuration; diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index b0b1a235..6eede4ad 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -35,12 +35,12 @@ import { act } from 'react'; import { useHaapiStepper } from './HaapiStepperHook'; import type { HaapiStepperCompletedStep, + HaapiStepperBootstrapConfig, HaapiStepperHistoryEntry, HaapiStepperNextStepAction, } from './haapi-stepper.types'; import { HaapiStepperActionStep, HaapiStepperFormAction } from './haapi-stepper.types'; import { isQrCodeLink } from '../../util/isQrCodeLink'; -import type { BootstrapConfiguration } from '../../data-access/bootstrap-configuration'; import { createMockWebAuthnAnyDeviceBothOptionsAction, createMockWebAuthnAuthenticationAction, @@ -57,14 +57,87 @@ describe('HaapiStepper', () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal('location', { href: '' }); + vi.stubGlobal('__CONFIG__', mockConfiguration); mockHaapiFetchStep(initialStepType); }); afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); mockHaapiFetch.mockReset(); }); + describe('Configuration modes', () => { + it('served mode: defaults the bootstrap config to window.__CONFIG__ when no config.bootstrap is passed', () => { + render( + + + + ); + + expect(mockHaapiFetch).toHaveBeenCalledTimes(1); + expect(mockHaapiFetch).toHaveBeenCalledWith(mockConfiguration.initialUrl, { method: 'GET' }); + }); + + it('standalone (library) mode: uses config.bootstrap supplied by the consumer, ignoring the default', () => { + const standaloneBootstrapConfig: HaapiStepperBootstrapConfig = { + initialUrl: 'https://standalone.example/start', + haapi: {} as HaapiStepperBootstrapConfig['haapi'], + }; + + render( + + + + ); + + expect(mockHaapiFetch).toHaveBeenCalledTimes(1); + expect(mockHaapiFetch).toHaveBeenCalledWith(standaloneBootstrapConfig.initialUrl, { method: 'GET' }); + }); + + it('standalone (library) mode: works without window.__CONFIG__ when config.bootstrap is supplied', async () => { + vi.stubGlobal('__CONFIG__', undefined); + + const standaloneBootstrapConfig: HaapiStepperBootstrapConfig = { + initialUrl: 'https://standalone.example/start', + haapi: {} as HaapiStepperBootstrapConfig['haapi'], + }; + + render( + + + + ); + + expect(mockHaapiFetch).toHaveBeenCalledTimes(1); + expect(mockHaapiFetch).toHaveBeenCalledWith(standaloneBootstrapConfig.initialUrl, { method: 'GET' }); + + const stepRendered = await screen.findByTestId('step-type'); + expect(stepRendered).toHaveTextContent(initialStepType); + + await goToNextStep(HAAPI_STEPS.POLLING); + await waitFor(() => { + expect(stepRendered).toHaveTextContent(HAAPI_STEPS.POLLING); + }); + }); + + it('throws an actionable error when neither window.__CONFIG__ nor config.bootstrap is available', () => { + vi.stubGlobal('__CONFIG__', undefined); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockReturnValue(undefined); + + expect(() => + render( + + + + ) + ).toThrow(/no bootstrap configuration available.*config\.bootstrap.*window\.__CONFIG__/s); + + consoleErrorSpy.mockRestore(); + }); + }); + describe('Steps', () => { it('should initialize the first step with the bootstrap initial URL and render the children', async () => { render( @@ -86,9 +159,7 @@ describe('HaapiStepper', () => { const overrideUrl = 'https://override.example/start'; render( - + ); @@ -1232,17 +1303,13 @@ vi.mock('@curity/identityserver-haapi-web-driver', () => { }; }); -const mockConfiguration: Partial = vi.hoisted(() => { - return { - initialUrl: 'https://example.com/auth', - haapi: {} as BootstrapConfiguration['haapi'], - }; -}); -vi.mock('../../data-access/bootstrap-configuration', () => { - return { - configuration: mockConfiguration, - }; -}); +// Default served-mode bootstrap stubbed onto `window.__CONFIG__` in `beforeEach`. +// Per-test code that needs to simulate a different scenario (library mode without +// a global, failure mode, …) calls `vi.stubGlobal('__CONFIG__', …)` to override. +const mockConfiguration: Partial = vi.hoisted(() => ({ + initialUrl: 'https://example.com/auth', + haapi: {} as HaapiStepperBootstrapConfig['haapi'], +})); const mockThrowErrorToAppErrorBoundary = vi.fn(); vi.mock('../../util/useThrowErrorToAppErrorBoundary', () => ({ diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx index b7107559..2e25f548 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx @@ -28,8 +28,6 @@ import { handlePollingStep } from './data-formatters/polling-step'; import { formatErrorStepData } from './data-formatters/problem-step'; import { formatNextStepData } from './data-formatters/format-next-step-data'; import { handleCompletedStep } from './step-handlers/completed-step'; -import { sendHaapiFetchRequest } from '../../data-access/happi-fetch-request'; -import { configuration } from '../../data-access/bootstrap-configuration'; import type { HaapiStepperClientOperationAction, HaapiStepperConfig, @@ -47,14 +45,6 @@ import { useThrowErrorToAppErrorBoundary } from '../../util/useThrowErrorToAppEr import { useRefCallback } from '../../util/useRefCallBack'; import { handleAuthenticationOrRegistrationStep } from './step-handlers/authentication-or-registration-step'; -const DEFAULT_CONFIG: Required = { - bootstrap: configuration, - pollingInterval: 3000, - bankIdAutostart: true, - autoRedirectOnAuthenticationComplete: true, - webAuthnAutostart: true, -}; - interface HaapiStepperProps { children: ReactNode; config?: Partial; @@ -84,6 +74,30 @@ type SetCurrentStepAndUpdateHistoryFn = ( * - **Error Handling**: Provides comprehensive error state management with user feedback. * - **Type Safety**: Offers full TypeScript support with strict typing. * + * ## Configuration modes + * + * The HaapiStepper supports two ways of receiving its bootstrap configuration + * (`initialUrl`, HAAPI driver config, theme): + * + * 1. **Served mode (default)** — the stepper runs inside a server-rendered + * shell (e.g. the Curity Login Web App) that injects the config onto + * `window.__CONFIG__` before the SPA boots. No prop is required: + * + * ```tsx + * ... + * ``` + * + * 2. **Standalone (library) mode** — when consumed as a library or in any + * context without `window.__CONFIG__`, the consumer supplies the bootstrap + * explicitly via `config.bootstrap`. + * + * ```tsx + * ... + * ``` + * + * See the [HAAPI Stepper README](../../README.md#basic-setup) for the full + * configuration reference. + * * ## HAAPI Stepper API * * Child components can access the following API via the `useHaapiStepper()` hook: @@ -273,7 +287,7 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { const [history, setHistory] = useState([]); const throwErrorToAppErrorBoundary = useThrowErrorToAppErrorBoundary(); const pendingOperation = useRef(null); - const configResult = useMemo(() => ({ ...DEFAULT_CONFIG, ...config }), [config]); + const configResult = useMemo(() => resolveStepperConfig(config), [config]); const { sendHaapiFetchRequest } = useHaapiFetch(configResult.bootstrap.haapi); const setCurrentStepAndUpdateHistory = useCallback( @@ -459,3 +473,20 @@ function getInitialStepLink(initialUrl: string) { return initialStepLink; } + +function resolveStepperConfig(config: Partial | undefined): Required { + const { bootstrap, ...configResult } = { + pollingInterval: 3000, + bankIdAutostart: true, + webAuthnAutostart: true, + autoRedirectOnAuthenticationComplete: true, + bootstrap: window.__CONFIG__, + ...config, + }; + if (!bootstrap) { + throw new Error( + 'HaapiStepper: no bootstrap configuration available. Pass it via the `config.bootstrap` prop or ensure `window.__CONFIG__` is set.' + ); + } + return { ...configResult, bootstrap }; +} diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/haapi-stepper.types.ts b/src/login-web-app/src/haapi-stepper/feature/stepper/haapi-stepper.types.ts index 5e34fd5c..eba10ec2 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/haapi-stepper.types.ts +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/haapi-stepper.types.ts @@ -53,7 +53,7 @@ import { HaapiTextFormField, HaapiUsernameFormField, } from '../../data-access'; -import { BootstrapConfiguration } from '../../data-access/bootstrap-configuration'; +import { HaapiConfiguration } from '@curity/identityserver-haapi-web-driver'; /** * Public API provided by the `HaapiStepper`, accessed via the `useHaapiStepper` hook. @@ -75,13 +75,24 @@ export interface HaapiStepperAPI { * CONFIG TYPINGS */ export interface HaapiStepperConfig { - bootstrap: BootstrapConfiguration; + bootstrap: HaapiStepperBootstrapConfig; pollingInterval: number; bankIdAutostart: boolean; webAuthnAutostart: boolean; autoRedirectOnAuthenticationComplete: boolean; } +export interface HaapiStepperBootstrapConfig { + initialUrl: string; + haapi: HaapiConfiguration; +} + +declare global { + interface Window { + __CONFIG__?: HaapiStepperBootstrapConfig; + } +} + /* * STEP TYPINGS */ diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/step-handlers/completed-step.ts b/src/login-web-app/src/haapi-stepper/feature/stepper/step-handlers/completed-step.ts index f9629026..ace7d7dc 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/step-handlers/completed-step.ts +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/step-handlers/completed-step.ts @@ -10,7 +10,7 @@ */ import { HAAPI_STEPS, type HaapiCompletedStep } from '../../../data-access/types/haapi-step.types'; -import type { HaapiStepperConfig } from '../HaapiStepper'; +import type { HaapiStepperConfig } from '../haapi-stepper.types'; import { formatNextStepData } from '../data-formatters/format-next-step-data'; export function handleCompletedStep(step: HaapiCompletedStep, config: HaapiStepperConfig) { diff --git a/src/login-web-app/src/shared/feature/app-config/AppConfig.tsx b/src/login-web-app/src/shared/feature/app-config/AppConfig.tsx deleted file mode 100644 index 139d1c34..00000000 --- a/src/login-web-app/src/shared/feature/app-config/AppConfig.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 { ReactNode } from 'react'; -import { configuration } from '../../../haapi-stepper/data-access/bootstrap-configuration'; -import { AppConfigContext } from './AppConfigContext'; - -export const AppConfig = ({ children }: { children: ReactNode }) => ( - {children} -); diff --git a/src/login-web-app/src/shared/feature/app-config/AppConfigContext.tsx b/src/login-web-app/src/shared/feature/app-config/HaapiAppConfigContext.tsx similarity index 65% rename from src/login-web-app/src/shared/feature/app-config/AppConfigContext.tsx rename to src/login-web-app/src/shared/feature/app-config/HaapiAppConfigContext.tsx index a25bd6f1..72bd1941 100644 --- a/src/login-web-app/src/shared/feature/app-config/AppConfigContext.tsx +++ b/src/login-web-app/src/shared/feature/app-config/HaapiAppConfigContext.tsx @@ -10,6 +10,6 @@ */ import { createContext } from 'react'; -import type { BootstrapConfiguration } from '../../../haapi-stepper/data-access/bootstrap-configuration'; +import { HaapiAppConfig } from './types'; -export const AppConfigContext = createContext(null); +export const HaapiAppConfigContext = createContext(null); diff --git a/src/login-web-app/src/shared/feature/app-config/AppConfigHook.ts b/src/login-web-app/src/shared/feature/app-config/HaapiAppConfigHook.ts similarity index 50% rename from src/login-web-app/src/shared/feature/app-config/AppConfigHook.ts rename to src/login-web-app/src/shared/feature/app-config/HaapiAppConfigHook.ts index 132d181e..4fab3965 100644 --- a/src/login-web-app/src/shared/feature/app-config/AppConfigHook.ts +++ b/src/login-web-app/src/shared/feature/app-config/HaapiAppConfigHook.ts @@ -10,27 +10,27 @@ */ import { use } from 'react'; -import { AppConfigContext } from './AppConfigContext'; +import { HaapiAppConfigContext } from './HaapiAppConfigContext'; /** - * Hook to access the bootstrap configuration provided by ``. + * Hook to access the bootstrap configuration provided by ``. * - * @throws {Error} If used outside of an ``. + * @throws {Error} If used outside of a `` provider. * * @example * ```tsx * function Logo() { - * const { theme } = useAppConfig(); + * const { theme } = useHaapiAppConfig(); * return ; * } * ``` */ -export function useAppConfig() { - const appConfig = use(AppConfigContext); +export function useHaapiAppConfig() { + const haapiAppConfig = use(HaapiAppConfigContext); - if (!appConfig) { - throw new Error('useAppConfig must be used inside AppConfigProvider'); + if (!haapiAppConfig) { + throw new Error('useHaapiAppConfig must be used inside HaapiAppConfig'); } - return appConfig; + return haapiAppConfig; } diff --git a/src/login-web-app/src/shared/feature/app-config/HaapiAppConfigProvider.tsx b/src/login-web-app/src/shared/feature/app-config/HaapiAppConfigProvider.tsx new file mode 100644 index 00000000..34e3a235 --- /dev/null +++ b/src/login-web-app/src/shared/feature/app-config/HaapiAppConfigProvider.tsx @@ -0,0 +1,22 @@ +/* + * 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 { ReactNode } from 'react'; +import { HaapiAppConfigContext } from './HaapiAppConfigContext'; +import { HaapiAppConfig } from './types'; + +export const HaapiAppConfigProvider = ({ children }: { children: ReactNode }) => { + const configuration = window.__CONFIG__ as HaapiAppConfig | undefined; + if (!configuration) { + throw new Error('HaapiAppConfigProvider: window.__CONFIG__ is not set'); + } + return {children}; +}; diff --git a/src/login-web-app/src/shared/feature/app-config/types.ts b/src/login-web-app/src/shared/feature/app-config/types.ts new file mode 100644 index 00000000..da03d677 --- /dev/null +++ b/src/login-web-app/src/shared/feature/app-config/types.ts @@ -0,0 +1,10 @@ +import { HaapiStepperBootstrapConfig } from '../../../haapi-stepper/feature'; + +export interface HaapiAppConfig extends HaapiStepperBootstrapConfig { + theme: { + logo: { + path: string; + isInsideWell: boolean; + }; + }; +} diff --git a/src/login-web-app/src/shared/ui/Layout.spec.tsx b/src/login-web-app/src/shared/ui/Layout.spec.tsx index 1a598ee9..7c73072e 100644 --- a/src/login-web-app/src/shared/ui/Layout.spec.tsx +++ b/src/login-web-app/src/shared/ui/Layout.spec.tsx @@ -12,21 +12,21 @@ import { describe, expect, it } from 'vitest'; import { render } from '@testing-library/react'; import { Layout } from './Layout'; -import { AppConfigContext } from '../feature/app-config/AppConfigContext'; -import type { BootstrapConfiguration } from '../../haapi-stepper/data-access/bootstrap-configuration'; +import { HaapiAppConfigContext } from '../feature/app-config/HaapiAppConfigContext'; +import { HaapiAppConfig } from '../feature/app-config/types'; const renderLayout = (isInsideWell: boolean) => { - const config: BootstrapConfiguration = { + const config: HaapiAppConfig = { initialUrl: 'https://example/start', - haapi: {} as BootstrapConfiguration['haapi'], + haapi: {} as HaapiAppConfig['haapi'], theme: { logo: { path: '/assets/logo.svg', isInsideWell } }, }; return render( - +
- + ); }; diff --git a/src/login-web-app/src/shared/ui/Layout.tsx b/src/login-web-app/src/shared/ui/Layout.tsx index 45cfaad7..fff275f0 100644 --- a/src/login-web-app/src/shared/ui/Layout.tsx +++ b/src/login-web-app/src/shared/ui/Layout.tsx @@ -11,11 +11,11 @@ import { ReactNode } from 'react'; import { Well } from '../../haapi-stepper/ui/well/Well'; -import { useAppConfig } from '../feature/app-config/AppConfigHook'; +import { useHaapiAppConfig } from '../feature/app-config/HaapiAppConfigHook'; import { Logo } from './Logo'; export const Layout = ({ children }: { children: ReactNode }) => { - const { isInsideWell } = useAppConfig().theme.logo; + const { isInsideWell } = useHaapiAppConfig().theme.logo; return ( <> diff --git a/src/login-web-app/src/shared/ui/Logo.spec.tsx b/src/login-web-app/src/shared/ui/Logo.spec.tsx index 5502a4e5..04b1bd53 100644 --- a/src/login-web-app/src/shared/ui/Logo.spec.tsx +++ b/src/login-web-app/src/shared/ui/Logo.spec.tsx @@ -12,21 +12,21 @@ import { describe, expect, it } from 'vitest'; import { render, screen } from '@testing-library/react'; import { Logo } from './Logo'; -import { AppConfigContext } from '../feature/app-config/AppConfigContext'; -import type { BootstrapConfiguration } from '../../haapi-stepper/data-access/bootstrap-configuration'; +import { HaapiAppConfigContext } from '../feature/app-config/HaapiAppConfigContext'; +import { HaapiAppConfig } from '../feature/app-config/types'; -const buildConfig = (logoPath: string): BootstrapConfiguration => ({ +const buildConfig = (logoPath: string): HaapiAppConfig => ({ initialUrl: 'https://example/start', - haapi: {} as BootstrapConfiguration['haapi'], + haapi: {} as HaapiAppConfig['haapi'], theme: { logo: { path: logoPath, isInsideWell: false } }, }); describe('Logo', () => { it('renders an image with the src from theme.logo.path', () => { render( - + - + ); const img = screen.getByRole('presentation'); diff --git a/src/login-web-app/src/shared/ui/Logo.tsx b/src/login-web-app/src/shared/ui/Logo.tsx index ef274f1e..0bb46d3e 100644 --- a/src/login-web-app/src/shared/ui/Logo.tsx +++ b/src/login-web-app/src/shared/ui/Logo.tsx @@ -9,9 +9,9 @@ * For further information, please contact Curity AB. */ -import { useAppConfig } from '../feature/app-config/AppConfigHook'; +import { useHaapiAppConfig } from '../feature/app-config/HaapiAppConfigHook'; export const Logo = () => { - const { theme } = useAppConfig(); + const { theme } = useHaapiAppConfig(); return ; }; From df02eb6f83997e20bf5936233a150a35d7204cfb Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Mon, 1 Jun 2026 10:31:00 +0200 Subject: [PATCH 3/6] IS-11380: improve haapifetch singleton enforcement --- src/login-web-app/src/haapi-stepper/README.md | 6 ++- .../data-access/useHaapiFetch.spec.ts | 53 +++++++++++++++++++ .../data-access/useHaapiFetch.ts | 43 ++++++++++++--- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/README.md b/src/login-web-app/src/haapi-stepper/README.md index 98feeac2..cd5461e8 100644 --- a/src/login-web-app/src/haapi-stepper/README.md +++ b/src/login-web-app/src/haapi-stepper/README.md @@ -80,7 +80,11 @@ const { currentStep, loading, error, nextStep } = useHaapiStepper(); #### Bootstrap Configuration -The `HaapiStepper` needs a **bootstrap configuration** — at minimum an `initialUrl` (where the flow starts) and a `haapi` driver config (the HAAPI web-driver settings). It supports two delivery modes, designed for two different deployment shapes: +The `HaapiStepper` needs a **bootstrap configuration** — at minimum an `initialUrl` (where the flow starts) and a `haapi` driver config (the HAAPI web-driver settings). + +> Only one HAAPI configuration is supported per page load — the underlying driver is a process-global singleton; switching `bootstrap.haapi` mid-page throws (see [`useHaapiFetch.ts`](./data-access/useHaapiFetch.ts)). + +The bootstrap configuration supports two delivery modes, designed for two different deployment shapes: ##### Served mode (default) diff --git a/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.spec.ts b/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.spec.ts index fa6b1241..b9166ca2 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.spec.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.spec.ts @@ -90,4 +90,57 @@ describe('useHaapiFetch', () => { expect(init.body).toBeInstanceOf(URLSearchParams); expect((init.body as URLSearchParams).get('username')).toBe('alice'); }); + + describe('Single-config contract', () => { + beforeEach(() => { + vi.resetModules(); + createHaapiFetchSpy.mockClear(); + }); + + it('tolerates reference churn when the config values are unchanged (does not re-create the driver)', async () => { + const { useHaapiFetch: useFreshHaapiFetch } = await import('./useHaapiFetch'); + + const configA = { + clientId: 'app-x', + tokenEndpoint: 'https://example/token', + } as HaapiConfiguration; + const configAClone = { + clientId: 'app-x', + tokenEndpoint: 'https://example/token', + } as HaapiConfiguration; + + const { rerender } = renderHook(({ config }) => useFreshHaapiFetch(config), { + initialProps: { config: configA }, + }); + rerender({ config: configAClone }); + + expect(createHaapiFetchSpy).toHaveBeenCalledTimes(1); + expect(createHaapiFetchSpy).toHaveBeenCalledWith(configA); + }); + + it('throws an actionable error when a later call arrives with a semantically different config', async () => { + const { useHaapiFetch: useFreshHaapiFetch } = await import('./useHaapiFetch'); + + const configA = { + clientId: 'app-x', + tokenEndpoint: 'https://example/token', + } as HaapiConfiguration; + const configB = { + clientId: 'app-y', // ◄── different OAuth client identity + tokenEndpoint: 'https://example/token', + } as HaapiConfiguration; + + const { rerender } = renderHook(({ config }) => useFreshHaapiFetch(config), { + initialProps: { config: configA }, + }); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockReturnValue(undefined); + + expect(() => rerender({ config: configB })).toThrow( + /HaapiConfiguration changed.*one configuration per page load.*reload the page/s + ); + + consoleErrorSpy.mockRestore(); + }); + }); }); diff --git a/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.ts b/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.ts index 95e228ce..edd4777c 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.ts @@ -15,16 +15,17 @@ import type { FetchLike, HaapiConfiguration } from '@curity/identityserver-haapi import { sendHaapiFetchRequest } from './happi-fetch-request'; import type { HaapiFetchAction } from './types/haapi-fetch.types'; -// The @curity/identityserver-haapi-web-driver is a *process-global singleton*: +// `@curity/identityserver-haapi-web-driver` is a *process-global singleton*: // the docs state "at most one active fetch-like function", and in practice -// `createHaapiFetch` registers an iframe + postMessage channel that can't -// survive rapid create/close/create cycles (e.g. React StrictMode dev). -// Caching the created fetch function allows us to reuse it and avoid such issues. +// `createHaapiFetch` registers an iframe + postMessage channel that can't be +// duplicated. To enforce this, we cache the driver's fetch function at the +// module level and throw if a different `HaapiConfiguration` is supplied on a +// subsequent call. +// +// This also has the benefit of ensuring that all components using `useHaapiFetch` +// share the same driver instance, even across StrictMode remounts. let cachedHaapiFetch: FetchLike | undefined; - -function getHaapiFetch(haapi: HaapiConfiguration): FetchLike { - return (cachedHaapiFetch ??= createHaapiFetch(haapi)); -} +let cachedConfig: HaapiConfiguration | undefined; export function useHaapiFetch(haapi: HaapiConfiguration) { const haapiFetch = useMemo(() => getHaapiFetch(haapi), [haapi]); @@ -35,3 +36,29 @@ export function useHaapiFetch(haapi: HaapiConfiguration) { [haapiFetch] ); } + +function getHaapiFetch(haapi: HaapiConfiguration): FetchLike { + if (isNewHaapiConfig(haapi)) { + throw new Error( + 'useHaapiFetch: HaapiConfiguration changed but the underlying HAAPI driver only ' + + 'supports one configuration per page load. To switch configurations, reload the page.' + ); + } + if (!cachedHaapiFetch) { + cachedConfig = haapi; + cachedHaapiFetch = createHaapiFetch(haapi); + } + return cachedHaapiFetch; +} + +function isNewHaapiConfig(haapi: HaapiConfiguration): boolean { + if (!cachedHaapiFetch || !cachedConfig) { + return false; + } + return ( + cachedConfig.clientId !== haapi.clientId || + cachedConfig.tokenEndpoint !== haapi.tokenEndpoint || + cachedConfig.baseUrl !== haapi.baseUrl || + cachedConfig.deviceIdentifier !== haapi.deviceIdentifier + ); +} From 6030940348333bab3db46a336183449da7e75290 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Mon, 1 Jun 2026 12:37:44 +0200 Subject: [PATCH 4/6] IS-11380: fix auto-start test --- .../src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index 6eede4ad..d16aa751 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -506,8 +506,7 @@ describe('HaapiStepper', () => { }); it('should not auto-start when WebAuthn API is not supported', async () => { - // Remove the PublicKeyCredential stub installed by beforeEach so isWebAuthnApiSupported() returns false. - vi.unstubAllGlobals(); + vi.stubGlobal('PublicKeyCredential', undefined); mockHaapiFetchWebAuthnStep(HAAPI_STEPS.REGISTRATION, createMockWebAuthnRegistrationAction()); render( From cc4a1314a04ababbeb7381caa096c4de78df5d42 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Mon, 1 Jun 2026 12:59:25 +0200 Subject: [PATCH 5/6] IS-11380: make logo optional --- .../src/shared/feature/app-config/types.ts | 2 +- .../src/shared/ui/Layout.spec.tsx | 20 +++++++++++++++++++ src/login-web-app/src/shared/ui/Layout.tsx | 2 +- src/login-web-app/src/shared/ui/Logo.spec.tsx | 17 ++++++++++++++++ src/login-web-app/src/shared/ui/Logo.tsx | 6 +++++- 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/login-web-app/src/shared/feature/app-config/types.ts b/src/login-web-app/src/shared/feature/app-config/types.ts index da03d677..6d1e7d91 100644 --- a/src/login-web-app/src/shared/feature/app-config/types.ts +++ b/src/login-web-app/src/shared/feature/app-config/types.ts @@ -2,7 +2,7 @@ import { HaapiStepperBootstrapConfig } from '../../../haapi-stepper/feature'; export interface HaapiAppConfig extends HaapiStepperBootstrapConfig { theme: { - logo: { + logo?: { path: string; isInsideWell: boolean; }; diff --git a/src/login-web-app/src/shared/ui/Layout.spec.tsx b/src/login-web-app/src/shared/ui/Layout.spec.tsx index 7c73072e..aed78de9 100644 --- a/src/login-web-app/src/shared/ui/Layout.spec.tsx +++ b/src/login-web-app/src/shared/ui/Layout.spec.tsx @@ -58,4 +58,24 @@ describe('Layout — logo placement', () => { // Logo appears before the well in document order. expect(logo!.compareDocumentPosition(well!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + + it('renders children and the well but no logo element when theme.logo is not configured', () => { + const configWithoutLogo: HaapiAppConfig = { + initialUrl: 'https://example/start', + haapi: {} as HaapiAppConfig['haapi'], + theme: {}, + }; + + const { container, getByTestId } = render( + + +
+ + + ); + + expect(getByTestId('content')).toBeInTheDocument(); + expect(container.querySelector('.haapi-stepper-well')).not.toBeNull(); + expect(container.querySelector('img.haapi-stepper-logo')).toBeNull(); + }); }); diff --git a/src/login-web-app/src/shared/ui/Layout.tsx b/src/login-web-app/src/shared/ui/Layout.tsx index fff275f0..128cf2c0 100644 --- a/src/login-web-app/src/shared/ui/Layout.tsx +++ b/src/login-web-app/src/shared/ui/Layout.tsx @@ -15,7 +15,7 @@ import { useHaapiAppConfig } from '../feature/app-config/HaapiAppConfigHook'; import { Logo } from './Logo'; export const Layout = ({ children }: { children: ReactNode }) => { - const { isInsideWell } = useHaapiAppConfig().theme.logo; + const { isInsideWell } = useHaapiAppConfig().theme?.logo ?? {}; return ( <> diff --git a/src/login-web-app/src/shared/ui/Logo.spec.tsx b/src/login-web-app/src/shared/ui/Logo.spec.tsx index 04b1bd53..79a6cce6 100644 --- a/src/login-web-app/src/shared/ui/Logo.spec.tsx +++ b/src/login-web-app/src/shared/ui/Logo.spec.tsx @@ -34,4 +34,21 @@ describe('Logo', () => { expect(img).toHaveAttribute('src', '/assets/logo.svg'); expect(img).toHaveClass('haapi-stepper-logo'); }); + + it('renders nothing when theme.logo is not configured', () => { + const configWithoutLogo: HaapiAppConfig = { + initialUrl: 'https://example/start', + haapi: {} as HaapiAppConfig['haapi'], + theme: {}, + }; + + const { container } = render( + + + + ); + + expect(container.querySelector('img')).toBeNull(); + expect(screen.queryByRole('presentation')).toBeNull(); + }); }); diff --git a/src/login-web-app/src/shared/ui/Logo.tsx b/src/login-web-app/src/shared/ui/Logo.tsx index 0bb46d3e..5bf14ea7 100644 --- a/src/login-web-app/src/shared/ui/Logo.tsx +++ b/src/login-web-app/src/shared/ui/Logo.tsx @@ -13,5 +13,9 @@ import { useHaapiAppConfig } from '../feature/app-config/HaapiAppConfigHook'; export const Logo = () => { const { theme } = useHaapiAppConfig(); - return ; + const logo = theme?.logo; + if (!logo) { + return null; + } + return ; }; From e6cfba6bb9097c124bdb34b110f568d54f2dcfc9 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Mon, 1 Jun 2026 14:22:06 +0200 Subject: [PATCH 6/6] IS-11380: fix lint issues --- src/login-web-app/src/shared/ui/Layout.tsx | 2 +- src/login-web-app/src/shared/ui/Logo.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/login-web-app/src/shared/ui/Layout.tsx b/src/login-web-app/src/shared/ui/Layout.tsx index 128cf2c0..a1388075 100644 --- a/src/login-web-app/src/shared/ui/Layout.tsx +++ b/src/login-web-app/src/shared/ui/Layout.tsx @@ -15,7 +15,7 @@ import { useHaapiAppConfig } from '../feature/app-config/HaapiAppConfigHook'; import { Logo } from './Logo'; export const Layout = ({ children }: { children: ReactNode }) => { - const { isInsideWell } = useHaapiAppConfig().theme?.logo ?? {}; + const { isInsideWell } = useHaapiAppConfig().theme.logo ?? {}; return ( <> diff --git a/src/login-web-app/src/shared/ui/Logo.tsx b/src/login-web-app/src/shared/ui/Logo.tsx index 5bf14ea7..378d3060 100644 --- a/src/login-web-app/src/shared/ui/Logo.tsx +++ b/src/login-web-app/src/shared/ui/Logo.tsx @@ -13,7 +13,7 @@ import { useHaapiAppConfig } from '../feature/app-config/HaapiAppConfigHook'; export const Logo = () => { const { theme } = useHaapiAppConfig(); - const logo = theme?.logo; + const logo = theme.logo; if (!logo) { return null; }