Skip to content
Open
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
6 changes: 3 additions & 3 deletions src/login-web-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
*/

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';
import { HaapiStepperErrorNotifier } from './haapi-stepper/feature/stepper/HaapiStepperErrorNotifier';

export function App() {
return (
<AppConfig>
<HaapiAppConfigProvider>
<ErrorBoundary>
<HaapiStepper>
<Layout>
Expand All @@ -28,6 +28,6 @@ export function App() {
</Layout>
</HaapiStepper>
</ErrorBoundary>
</AppConfig>
</HaapiAppConfigProvider>
);
}
42 changes: 35 additions & 7 deletions src/login-web-app/src/haapi-stepper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<HaapiStepper>
<HaapiComponentExample />
</HaapiStepper>
);
}
// The shell has already injected window.__CONFIG__ — just mount the stepper.
<HaapiStepper>
<HaapiStepperStepUI />
</HaapiStepper>
```

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 } },
};

<HaapiStepper config={{ bootstrap: bootstrapConfig }}>
<HaapiStepperStepUI />
</HaapiStepper>
```

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.
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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<HaapiStep> {
export async function sendHaapiFetchRequest(action: HaapiFetchAction, haapiFetch: FetchLike): Promise<HaapiStep> {
const request = createApiRequest(action);
const response = await haapiFetch(request.url, request.init);
const mediaType = response.headers.get('Content-Type');
Expand Down
1 change: 1 addition & 0 deletions src/login-web-app/src/haapi-stepper/data-access/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
export * from './haapi-fetch-utils';
export * from './happi-fetch-request';
export * from './types';
export * from './useHaapiFetch';
Original file line number Diff line number Diff line change
@@ -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');
});
});
37 changes: 37 additions & 0 deletions src/login-web-app/src/haapi-stepper/data-access/useHaapiFetch.ts
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
aleixsuau marked this conversation as resolved.
let cachedHaapiFetch: FetchLike | undefined;

function getHaapiFetch(haapi: HaapiConfiguration): FetchLike {
return (cachedHaapiFetch ??= createHaapiFetch(haapi));
}

export function useHaapiFetch(haapi: HaapiConfiguration) {
const haapiFetch = useMemo(() => getHaapiFetch(haapi), [haapi]);
Comment on lines +23 to +30
return useMemo(
() => ({
sendHaapiFetchRequest: (action: HaapiFetchAction) => sendHaapiFetchRequest(action, haapiFetch),
}),
[haapiFetch]
);
}
Loading
Loading