Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
Expand Up @@ -198,15 +198,55 @@ export interface HaapiWebAuthnRegistrationClientOperationAction extends HaapiCli

export interface HaapiWebAuthnRegistrationClientOperationModel extends HaapiBaseClientOperationModel {
name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION;
arguments:
| { credentialCreationOptions: HaapiPublicKeyCredentialCreationOptions }
| {
platformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions;
crossPlatformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions;
};
arguments: HaapiWebAuthnRegistrationArgs;
continueActions: [HaapiFormAction];
}

export type HaapiWebAuthnPasskeysRegistrationAction = Omit<HaapiWebAuthnRegistrationClientOperationAction, 'model'> & {
model: Omit<HaapiWebAuthnRegistrationClientOperationModel, 'arguments'> & {
arguments: HaapiWebAuthnPasskeysArgs;
};
};

export type HaapiWebAuthnAnyDeviceRegistrationAction = Omit<HaapiWebAuthnRegistrationClientOperationAction, 'model'> & {
model: Omit<HaapiWebAuthnRegistrationClientOperationModel, 'arguments'> & {
arguments: HaapiWebAuthnAnyDeviceArgs;
};
};

/**
* Discriminated union of `webauthn-registration` action arguments.
*
* - Passkeys-mode (`passkey` authenticator or `webauthn` in passkeys-mode): only
* `credentialCreationOptions` is present.
* - Any-device-mode (`webauthn` authenticator): one or both of
* `platformCredentialCreationOptions` / `crossPlatformCredentialCreationOptions`.
*/
export type HaapiWebAuthnRegistrationArgs = HaapiWebAuthnPasskeysArgs | HaapiWebAuthnAnyDeviceArgs;

export interface HaapiWebAuthnPasskeysArgs {
credentialCreationOptions: HaapiPublicKeyCredentialCreationOptions;
}

export interface HaapiWebAuthnAnyDeviceArgs {
platformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions;
crossPlatformCredentialCreationOptions?: HaapiPublicKeyCredentialCreationOptions;
}

/**
* Continue-action payload key for the `webauthn-registration` operation. The value matches the
* `*CreationOptions` key the client picked from `model.arguments`.
*
* - `CREDENTIAL` — passkeys-mode (when `HaapiWebAuthnPasskeysArgs.credentialCreationOptions` is present).
* - `PLATFORM_CREDENTIAL` — any-device-mode (when `HaapiWebAuthnAnyDeviceArgs.platformCredentialCreationOptions` is present).
* - `CROSS_PLATFORM_CREDENTIAL` — any-device-mode (when `HaapiWebAuthnAnyDeviceArgs.crossPlatformCredentialCreationOptions` is present).
*/
export enum HAAPI_WEBAUTHN_REGISTRATION_SELECTED_OPTION {
CREDENTIAL = 'credential',
PLATFORM_CREDENTIAL = 'platformCredential',
CROSS_PLATFORM_CREDENTIAL = 'crossPlatformCredential',
}

export interface HaapiPublicKeyCredentialCreationOptions {
publicKey: PublicKeyCredentialCreationOptionsJSON;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*
* Copyright (C) 2025 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import {
HAAPI_ACTION_CLIENT_OPERATIONS,
HAAPI_ACTION_TYPES,
HAAPI_FORM_ACTION_KINDS,
HaapiClientOperationAction,
} from '../../../data-access/types/haapi-action.types';
import { HAAPI_STEPS } from '../../../data-access/types/haapi-step.types';
import { HaapiStepperClientOperationAction } from '../../stepper/haapi-stepper.types';
import { createMockClientOperationAction, createMockFormAction, createMockStep } from '../../../util/tests/mocks';
import { HaapiStepperActionsUI } from '../../../ui/actions/HaapiStepperActionsUI';
import { HaapiStepperClientOperationUI } from './HaapiStepperClientOperationUI';

describe('HaapiStepperClientOperationUI', () => {
let user: ReturnType<typeof userEvent.setup>;

beforeEach(() => {
user = userEvent.setup();
});

describe('Default rendering', () => {
it('renders the action title as an enabled button', () => {
const action = createExternalBrowserFlowAction();

render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} />);

expect(screen.getByRole('button', { name: externalBrowserActionTitle })).toBeEnabled();
});

it('does not render a progress bar when the action has no remaining wait time', () => {
const action = createExternalBrowserFlowAction();

render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} />);

expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});

it('forwards the action to onAction when clicked', async () => {
const action = createExternalBrowserFlowAction();
const onAction = vi.fn();

render(<HaapiStepperClientOperationUI action={action} onAction={onAction} />);

await user.click(screen.getByRole('button', { name: externalBrowserActionTitle }));

expect(onAction).toHaveBeenCalledTimes(1);
expect(onAction).toHaveBeenCalledWith(action);
});
});

describe('BankID polling progress', () => {
it('renders a progress bar reflecting the session remaining time', () => {
const action = createBankIdAction({ maxWaitTime: 60, maxWaitRemainingTime: 30 });

render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} />);

const progress = screen.getByRole('progressbar');
expect(progress).toHaveAttribute('value', '30');
expect(progress).toHaveAttribute('max', '60');
});

it('hides the progress bar when showBankIdSessionTimeLeft is false', () => {
const action = createBankIdAction({ maxWaitTime: 60, maxWaitRemainingTime: 30 });

render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} showBankIdSessionTimeLeft={false} />);

expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});

describe('WebAuthn registration', () => {
beforeEach(() => {
vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential());
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('enables the button when the WebAuthn API is available', () => {
const action = createWebAuthnRegistrationAction();

render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} />);

expect(screen.getByRole('button', { name: webAuthnActionTitle })).toBeEnabled();
});
});
Comment thread
aleixsuau marked this conversation as resolved.
Outdated

describe('WebAuthn any-device split (integration)', () => {
it('renders one button per credential option when both are offered, suffixing the original title', () => {
const action = createWebAuthnAnyDeviceBothOptionsAction();
const step = createMockStep(HAAPI_STEPS.AUTHENTICATION, { actions: [action] });

render(<HaapiStepperActionsUI actions={step.dataHelpers.actions?.all} onAction={vi.fn()} />);

expect(screen.getByRole('button', { name: 'Register device (This device)' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Register device (Another device)' })).toBeInTheDocument();
});
});
});

const externalBrowserActionTitle = 'Continue in browser';
const webAuthnActionTitle = 'Register a passkey';

const WEBAUTHN_PUBLIC_KEY = {
challenge: 'c',
rp: { name: 'r' },
user: { id: 'u', name: 'n', displayName: 'd' },
pubKeyCredParams: [],
} as never;

const continueAction = createMockFormAction({
kind: HAAPI_FORM_ACTION_KINDS.CONTINUE,
title: 'Continue',
});

/**
* Minimal stand-in for the static `PublicKeyCredential` interface — enough for
* `isWebAuthnApiSupported()` to return true and for the platform-authenticator hook to resolve.
* jsdom doesn't expose `PublicKeyCredential`, so tests stub it to emulate a WebAuthn-capable
* browser without reaching into the real `navigator.credentials` API.
*/
const stubPublicKeyCredential = () =>
Object.assign(vi.fn(), {
parseCreationOptionsFromJSON: vi.fn(),
parseRequestOptionsFromJSON: vi.fn(),
isUserVerifyingPlatformAuthenticatorAvailable: vi.fn(() => Promise.resolve(true)),
});

const createExternalBrowserFlowAction = (
overrides: Partial<HaapiStepperClientOperationAction> = {}
): HaapiStepperClientOperationAction =>
createMockClientOperationAction({
title: externalBrowserActionTitle,
model: {
name: HAAPI_ACTION_CLIENT_OPERATIONS.EXTERNAL_BROWSER_FLOW,
arguments: { href: '/external-browser' },
continueActions: [continueAction],
},
...overrides,
});

const createBankIdAction = (
overrides: Partial<HaapiStepperClientOperationAction> = {}
): HaapiStepperClientOperationAction =>
createMockClientOperationAction({
title: 'Open BankID',
kind: 'bankid',
model: {
name: HAAPI_ACTION_CLIENT_OPERATIONS.BANKID,
arguments: { href: '/bankid', autoStartToken: 'token' },
continueActions: [continueAction],
},
...overrides,
});

const createWebAuthnRegistrationAction = (
overrides: Partial<HaapiStepperClientOperationAction> = {}
): HaapiStepperClientOperationAction =>
createMockClientOperationAction({
title: webAuthnActionTitle,
kind: 'device-register',
template: HAAPI_ACTION_TYPES.CLIENT_OPERATION,
model: {
name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION,
arguments: {
credentialCreationOptions: { publicKey: WEBAUTHN_PUBLIC_KEY },
},
continueActions: [continueAction],
},
...overrides,
});

const createWebAuthnAnyDeviceBothOptionsAction = (): HaapiClientOperationAction => ({
template: HAAPI_ACTION_TYPES.CLIENT_OPERATION,
kind: 'device-register',
title: 'Register device',
model: {
name: HAAPI_ACTION_CLIENT_OPERATIONS.WEBAUTHN_REGISTRATION,
arguments: {
platformCredentialCreationOptions: { publicKey: WEBAUTHN_PUBLIC_KEY },
crossPlatformCredentialCreationOptions: { publicKey: WEBAUTHN_PUBLIC_KEY },
},
continueActions: [continueAction],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,33 @@
* For further information, please contact Curity AB.
*/

import { ReactNode } from 'react';
import { HaapiStepperClientOperationAction, HaapiStepperFormAction } from '../../stepper/haapi-stepper.types';
import { useIsClientOperationAvailable } from './useIsClientOperationAvailable';

interface HaapiStepperClientOperationUIProps {
action: HaapiStepperClientOperationAction;
onAction: (action: HaapiStepperClientOperationAction | HaapiStepperFormAction) => void;
showBankIdSessionTimeLeft?: boolean;
render?: (
action: HaapiStepperClientOperationAction,
onAction: (action: HaapiStepperClientOperationAction | HaapiStepperFormAction) => void,
showBankIdSessionTimeLeft: boolean
) => ReactNode;
}
Comment thread
aleixsuau marked this conversation as resolved.

/**
* @description
* # CLIENT OPERATION ACTION COMPONENT
*
* Provides the default UI wrapper for HAAPI client-operation actions and exposes a render
* prop to fully customise the look and feel while keeping the underlying behaviour intact.
* It also forwards the client operation option clicked to the provided `onAction` handler.
* Renders the default UI for a HAAPI client-operation action and forwards the click to
* `onAction`. The button is disabled when the action's runtime capability requirements are
* not met (e.g. WebAuthn API missing, or platform authenticator unavailable for platform-only
* WebAuthn registration).
*
* @example
* ```tsx
* function HaapiComponentExample() {
* const { currentStep, nextStep } = useHaapiStepper(); *
* const { currentStep, nextStep } = useHaapiStepper();
* const clientOperationAction = currentStep?.dataHelpers.clientOperationActions?.[0];
*
* return { clientOperationAction && <HaapiStepperClientOperationUI action={clientOperationAction} onAction={nextStep} /> };
* return clientOperationAction && (
* <HaapiStepperClientOperationUI action={clientOperationAction} onAction={nextStep} />
* );
Comment thread
aleixsuau marked this conversation as resolved.
* }
*
* <HaapiStepper>
Expand All @@ -49,26 +47,26 @@ export function HaapiStepperClientOperationUI({
action,
onAction,
showBankIdSessionTimeLeft = true,
render = defaultRenderClientOperation,
}: HaapiStepperClientOperationUIProps) {
return render(action, onAction, showBankIdSessionTimeLeft);
}
const isAvailable = useIsClientOperationAvailable(action);

const defaultRenderClientOperation = (
action: HaapiStepperClientOperationAction,
onAction: (action: HaapiStepperClientOperationAction | HaapiStepperFormAction) => void,
showBankIdSessionTimeLeft: boolean
) => (
<div data-testid="client-operation-action">
{showBankIdSessionTimeLeft && action.maxWaitRemainingTime !== undefined && (
<progress
className="haapi-stepper-polling-progress"
value={action.maxWaitRemainingTime}
max={action.maxWaitTime}
/>
)}
<button type="button" className="haapi-stepper-button" onClick={() => onAction(action)}>
{action.title}
</button>
</div>
);
return (
<div data-testid="client-operation-action">
{showBankIdSessionTimeLeft && action.maxWaitRemainingTime !== undefined && (
<progress
className="haapi-stepper-polling-progress"
value={action.maxWaitRemainingTime}
max={action.maxWaitTime}
/>
)}
<button
type="button"
className="haapi-stepper-button"
disabled={!isAvailable}
onClick={() => onAction(action)}
>
{action.title}
</button>
</div>
);
}
Loading
Loading