Skip to content
Draft
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
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,158 @@
/*
* 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_STEPS } from '../../../data-access/types/haapi-step.types';
import {
createMockBankIdAction,
createMockExternalBrowserFlowAction,
createMockStep,
createMockWebAuthnAnyDeviceBothOptionsAction,
createMockWebAuthnPlatformOnlyAnyDeviceAction,
createMockWebAuthnRegistrationAction,
externalBrowserFlowActionTitle,
webAuthnAnyDeviceActionTitle,
webAuthnPlatformOnlyAnyDeviceActionTitle,
webAuthnRegistrationActionTitle,
} from '../../../util/tests/mocks';
import { HaapiStepperActionsUI } from '../../../ui/actions/HaapiStepperActionsUI';
import { HaapiStepperClientOperationUI } from './HaapiStepperClientOperationUI';
import { useIsWebAuthnPlatformAuthenticatorAvailable } from './operations/webauthn';

vi.mock('./operations/webauthn', async () => {
const actual = await vi.importActual('./operations/webauthn');
return {
...(actual as object),
useIsWebAuthnPlatformAuthenticatorAvailable: vi.fn(() => undefined),
};
});

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 = createMockExternalBrowserFlowAction();

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

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

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

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

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

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

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

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

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

describe('BankID polling progress', () => {
it('renders a progress bar reflecting the session remaining time', () => {
const action = createMockBankIdAction({ 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 = createMockBankIdAction({ maxWaitTime: 60, maxWaitRemainingTime: 30 });

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

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

describe('WebAuthn', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.mocked(useIsWebAuthnPlatformAuthenticatorAvailable).mockReset();
});

it('enables the button when the WebAuthn API is available', () => {
vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential());
const action = createMockWebAuthnRegistrationAction();

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

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

it('disables the button when the WebAuthn API is unavailable', () => {
// jsdom does not expose `PublicKeyCredential`, so `isWebAuthnApiSupported()` returns false
// and the gate disables WebAuthn buttons.
const action = createMockWebAuthnRegistrationAction();

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

expect(screen.getByRole('button', { name: webAuthnRegistrationActionTitle })).toBeDisabled();
});

it('disables a platform-only any-device registration when no platform authenticator is available', () => {
vi.stubGlobal('PublicKeyCredential', stubPublicKeyCredential());
vi.mocked(useIsWebAuthnPlatformAuthenticatorAvailable).mockReturnValue(false);
const action = createMockWebAuthnPlatformOnlyAnyDeviceAction();

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

expect(screen.getByRole('button', { name: webAuthnPlatformOnlyAnyDeviceActionTitle })).toBeDisabled();
});

it('renders one button per credential option for any-device-mode with both options, suffixing the original title', () => {
const action = createMockWebAuthnAnyDeviceBothOptionsAction();
const step = createMockStep(HAAPI_STEPS.AUTHENTICATION, { actions: [action] });

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

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

/**
* 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)),
});
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 clientOperationAction = currentStep?.dataHelpers.clientOperationActions?.[0];
* const { currentStep, nextStep } = useHaapiStepper();
* const clientOperationAction = currentStep?.dataHelpers.actions?.clientOperation?.[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,21 @@ 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