diff --git a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx index 4a9a9ccf..7b7b5514 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx @@ -23,12 +23,12 @@ import { createMockWebAuthnRegistrationAction, externalBrowserFlowActionTitle, webAuthnAnyDeviceActionTitle, - webAuthnPlatformOnlyAnyDeviceActionTitle, webAuthnRegistrationActionTitle, } from '../../../util/tests/mocks'; import { HaapiStepperActionsUI } from '../../../ui/actions/HaapiStepperActionsUI'; import { HaapiStepperClientOperationUI } from './HaapiStepperClientOperationUI'; -import { WebAuthnRegistrationAttachmentKind } from '../../stepper/haapi-stepper.types'; +import type { HaapiStepperStep } from '../../stepper/haapi-stepper.types'; +import { useHaapiStepper } from '../../stepper/HaapiStepperHook'; import { useIsWebAuthnPlatformAuthenticatorAvailable } from './operations/webauthn/useIsWebAuthnPlatformAuthenticatorAvailable'; const PLATFORM_TITLE = 'Built-in'; @@ -38,7 +38,6 @@ const CROSS_PLATFORM_TITLE = 'Security key'; const CROSS_PLATFORM_DESCRIPTION = 'A security key, such as a portable key attached through USB or wirelessly through NFC.'; const CROSS_PLATFORM_ICON_VIEW_BOX = '0 0 49 72.6'; -const REGISTER_VIEW_NAME = 'authenticator/webauthn/register/get'; const MESSAGE_PREFIX = 'authenticator.webauthn.register.view.'; const VIEW_DATA_MESSAGES = { [`${MESSAGE_PREFIX}button.platform`]: PLATFORM_TITLE, @@ -51,11 +50,25 @@ vi.mock('./operations/webauthn/useIsWebAuthnPlatformAuthenticatorAvailable', () useIsWebAuthnPlatformAuthenticatorAvailable: vi.fn(() => undefined), })); +// The attachment card resolves its copy from the current step's viewData messages via +// `useHaapiStepper`; tests drive that copy through the mocked hook. +vi.mock('../../stepper/HaapiStepperHook', () => ({ useHaapiStepper: vi.fn() })); + +const mockCurrentStepMessages = (messages?: Record) => + vi.mocked(useHaapiStepper).mockReturnValue({ + currentStep: (messages ? { metadata: { viewData: { messages } } } : null) as HaapiStepperStep | null, + } as ReturnType); + describe('HaapiStepperClientOperationUI', () => { let user: ReturnType; beforeEach(() => { user = userEvent.setup(); + mockCurrentStepMessages(VIEW_DATA_MESSAGES); + }); + + afterEach(() => { + vi.mocked(useHaapiStepper).mockReset(); }); describe('Default rendering', () => { @@ -113,26 +126,23 @@ describe('HaapiStepperClientOperationUI', () => { 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(); + it('renders a default button (not a card) for passkeys-mode registration', () => { + const action = createMockWebAuthnRegistrationAction(); render(); - expect(screen.getByRole('button', { name: webAuthnPlatformOnlyAnyDeviceActionTitle })).toBeDisabled(); + expect(screen.queryByTestId('webauthn-registration-attachment-card')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: webAuthnRegistrationActionTitle })).toBeInTheDocument(); }); - 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] }); + 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(); + render(); - expect(screen.getByRole('button', { name: `${webAuthnAnyDeviceActionTitle} (Built-in)` })).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: `${webAuthnAnyDeviceActionTitle} (Security key)` }) - ).toBeInTheDocument(); + expect(screen.getByTestId('webauthn-registration-attachment-card')).toBeDisabled(); }); describe('WebAuthn registration attachment card (any device mode (platform/cross-platform))', () => { @@ -140,7 +150,6 @@ describe('HaapiStepperClientOperationUI', () => { { name: 'platform', makeAction: createMockWebAuthnPlatformOnlyAnyDeviceAction, - kind: WebAuthnRegistrationAttachmentKind.PLATFORM, title: PLATFORM_TITLE, description: PLATFORM_DESCRIPTION, iconViewBox: PLATFORM_ICON_VIEW_BOX, @@ -148,17 +157,14 @@ describe('HaapiStepperClientOperationUI', () => { { name: 'cross-platform', makeAction: createMockWebAuthnCrossPlatformOnlyAnyDeviceAction, - kind: WebAuthnRegistrationAttachmentKind.CROSS_PLATFORM, title: CROSS_PLATFORM_TITLE, description: CROSS_PLATFORM_DESCRIPTION, iconViewBox: CROSS_PLATFORM_ICON_VIEW_BOX, }, ])( 'renders the $name attachment option as a card (title, description and matching icon)', - ({ makeAction, kind, title, description, iconViewBox }) => { - const action = { ...makeAction(), webauthn: { registrationAttachment: { kind, title, description } } }; - - const { container } = render(); + ({ makeAction, title, description, iconViewBox }) => { + const { container } = render(); expect(screen.getByTestId('webauthn-registration-attachment-card')).toBeInTheDocument(); expect(screen.getByText(title)).toBeInTheDocument(); @@ -170,10 +176,9 @@ describe('HaapiStepperClientOperationUI', () => { } ); - it('renders one card per option when a both-options any-device registration supplies messages', () => { + it('renders one card per option for a both-options any-device registration', () => { const step = createMockStep(HAAPI_STEPS.REGISTRATION, { actions: [createMockWebAuthnAnyDeviceBothOptionsAction()], - metadata: { viewName: REGISTER_VIEW_NAME, viewData: { messages: VIEW_DATA_MESSAGES } }, }); render(); @@ -184,6 +189,7 @@ describe('HaapiStepperClientOperationUI', () => { }); it('renders the cards with the split action titles as fallback when the step carries no messages', () => { + mockCurrentStepMessages(undefined); const step = createMockStep(HAAPI_STEPS.REGISTRATION, { actions: [createMockWebAuthnAnyDeviceBothOptionsAction()], }); @@ -197,16 +203,6 @@ describe('HaapiStepperClientOperationUI', () => { expect(cards[0].querySelector('.haapi-stepper-webauthn-registration-attachment-description')).toBeNull(); expect(cards[1].querySelector('.haapi-stepper-webauthn-registration-attachment-description')).toBeNull(); }); - - it('renders the default button (no card) when the action carries no webauthn data', () => { - render( - - ); - - expect(screen.queryByTestId('webauthn-registration-attachment-card')).not.toBeInTheDocument(); - expect(screen.getByTestId('client-operation-action')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: webAuthnPlatformOnlyAnyDeviceActionTitle })).toBeInTheDocument(); - }); }); }); }); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx index ade371ec..8a6302ba 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx @@ -11,6 +11,7 @@ import { HaapiStepperClientOperationAction, HaapiStepperFormAction } from '../../stepper/haapi-stepper.types'; import { HaapiStepperWebAuthnRegistrationAttachmentCard } from './operations/webauthn/HaapiStepperWebAuthnRegistrationAttachmentCard'; +import { isAnyDeviceWebAuthnRegistrationClientOperation } from './operations/webauthn'; import { useIsClientOperationAvailable } from './useIsClientOperationAvailable'; interface HaapiStepperClientOperationUIProps { @@ -46,10 +47,10 @@ interface HaapiStepperClientOperationUIProps { export function HaapiStepperClientOperationUI({ action, onAction }: HaapiStepperClientOperationUIProps) { const isAvailable = useIsClientOperationAvailable(action); - if (action.webauthn?.registrationAttachment) { + if (isAnyDeviceWebAuthnRegistrationClientOperation(action)) { return ( onAction(action)} /> diff --git a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/operations/webauthn/HaapiStepperWebAuthnRegistrationAttachmentCard.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/operations/webauthn/HaapiStepperWebAuthnRegistrationAttachmentCard.spec.tsx index e17570dd..0b46122d 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/operations/webauthn/HaapiStepperWebAuthnRegistrationAttachmentCard.spec.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/operations/webauthn/HaapiStepperWebAuthnRegistrationAttachmentCard.spec.tsx @@ -11,63 +11,72 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockWebAuthnCrossPlatformOnlyAnyDeviceAction, createMockWebAuthnPlatformOnlyAnyDeviceAction, + webAuthnPlatformOnlyAnyDeviceActionTitle, } from '../../../../../util/tests/mocks'; import { HaapiStepperWebAuthnRegistrationAttachmentCard } from './HaapiStepperWebAuthnRegistrationAttachmentCard'; -import { WebAuthnRegistrationAttachmentKind } from '../../../../stepper/haapi-stepper.types'; +import { useHaapiStepper } from '../../../../stepper/HaapiStepperHook'; +import type { HaapiStepperStep } from '../../../../stepper/haapi-stepper.types'; + +vi.mock('../../../../stepper/HaapiStepperHook', () => ({ useHaapiStepper: vi.fn() })); const PLATFORM_ICON_VIEW_BOX = '0 0 64.2 83.9'; const CROSS_PLATFORM_ICON_VIEW_BOX = '0 0 49 72.6'; +const PREFIX = 'authenticator.webauthn.register.view.'; +const PLATFORM_TITLE = 'Built-in'; +const PLATFORM_DESCRIPTION = 'A non-removable built-in device.'; +const CROSS_PLATFORM_TITLE = 'Security key'; + +// The card resolves its copy from the current step's viewData messages via `useHaapiStepper`. +const mockCurrentStepMessages = (messages?: Record) => + vi.mocked(useHaapiStepper).mockReturnValue({ + currentStep: (messages ? { metadata: { viewData: { messages } } } : null) as HaapiStepperStep | null, + } as ReturnType); + +// Safe default so a test that forgets to configure the hook gets a clear assertion +// failure rather than an opaque "cannot destructure currentStep" crash. +beforeEach(() => mockCurrentStepMessages(undefined)); + +afterEach(() => vi.mocked(useHaapiStepper).mockReset()); describe('HaapiStepperWebAuthnRegistrationAttachmentCard', () => { - it('renders the title, description and matching icon resolved in the action webauthn data', () => { - const action = { - ...createMockWebAuthnPlatformOnlyAnyDeviceAction(), - webauthn: { - registrationAttachment: { - kind: WebAuthnRegistrationAttachmentKind.PLATFORM, - title: 'Built-in', - description: 'A non-removable built-in device.', - }, - }, - }; + it("renders the title, description and matching icon from the step's data", () => { + mockCurrentStepMessages({ + [`${PREFIX}button.platform`]: PLATFORM_TITLE, + [`${PREFIX}authenticator-attachment.platform`]: PLATFORM_DESCRIPTION, + }); const { container } = render( ); - expect(screen.getByText('Built-in')).toBeInTheDocument(); - expect(screen.getByText('A non-removable built-in device.')).toBeInTheDocument(); + expect(screen.getByText(PLATFORM_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PLATFORM_DESCRIPTION)).toBeInTheDocument(); expect(container.querySelector('.haapi-stepper-webauthn-registration-attachment-icon svg')).toHaveAttribute( 'viewBox', PLATFORM_ICON_VIEW_BOX ); }); - it('omits the description when the webauthn data has none, still rendering the icon', () => { - const action = { - ...createMockWebAuthnCrossPlatformOnlyAnyDeviceAction(), - webauthn: { - registrationAttachment: { kind: WebAuthnRegistrationAttachmentKind.CROSS_PLATFORM, title: 'Security key' }, - }, - }; + it('omits the description when the step has no description message, still rendering the icon', () => { + mockCurrentStepMessages({ [`${PREFIX}button.cross-platform`]: CROSS_PLATFORM_TITLE }); const { container } = render( ); const card = screen.getByTestId('webauthn-registration-attachment-card'); - expect(screen.getByText('Security key')).toBeInTheDocument(); + expect(screen.getByText(CROSS_PLATFORM_TITLE)).toBeInTheDocument(); expect(card.querySelector('.haapi-stepper-webauthn-registration-attachment-description')).toBeNull(); expect(container.querySelector('.haapi-stepper-webauthn-registration-attachment-icon svg')).toHaveAttribute( 'viewBox', @@ -75,17 +84,27 @@ describe('HaapiStepperWebAuthnRegistrationAttachmentCard', () => { ); }); + it('falls back to the action title when the step carries no view-data', () => { + mockCurrentStepMessages(undefined); + + render( + + ); + + expect(screen.getByText(webAuthnPlatformOnlyAnyDeviceActionTitle)).toBeInTheDocument(); + }); + it('calls onClick when the card is clicked', async () => { const user = userEvent.setup(); const onClick = vi.fn(); - const action = { - ...createMockWebAuthnPlatformOnlyAnyDeviceAction(), - webauthn: { registrationAttachment: { kind: WebAuthnRegistrationAttachmentKind.PLATFORM, title: 'Built-in' } }, - }; + mockCurrentStepMessages(undefined); render( ); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/operations/webauthn/HaapiStepperWebAuthnRegistrationAttachmentCard.tsx b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/operations/webauthn/HaapiStepperWebAuthnRegistrationAttachmentCard.tsx index dd7f16b9..888bc549 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/operations/webauthn/HaapiStepperWebAuthnRegistrationAttachmentCard.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/operations/webauthn/HaapiStepperWebAuthnRegistrationAttachmentCard.tsx @@ -9,12 +9,14 @@ * For further information, please contact Curity AB. */ -import { HaapiStepperWebAuthnRegistrationAttachment } from '../../../../stepper/haapi-stepper.types'; +import { HaapiStepperWebAuthnAnyDeviceRegistrationAction } from '../../../../stepper/haapi-stepper.types'; +import { useHaapiStepper } from '../../../../stepper/HaapiStepperHook'; +import { getAnyDeviceWebAuthnRegistrationAttachment } from './webauthn-registration-attachment'; import { REGISTRATION_ATTACHMENT_ICON } from './webauthn-registration-attachment-icon-map'; export interface HaapiStepperWebAuthnRegistrationAttachmentCardProps { /** The (split) any-device `webauthn-registration` action this option advances the flow with. */ - registrationAttachment: HaapiStepperWebAuthnRegistrationAttachment; + action: HaapiStepperWebAuthnAnyDeviceRegistrationAction; /** Disables the card (e.g. when the required authenticator capability is unavailable). */ disabled?: boolean; onClick: () => void; @@ -24,14 +26,18 @@ export interface HaapiStepperWebAuthnRegistrationAttachmentCardProps { * A single WebAuthn registration attachment-selection option, rendered as a clickable card * (icon + bold title + description). * - * Exported so consumers building a custom WebAuthn registration UI can reuse it. + * Must be rendered within `HaapiStepper`, for the current active step's action. */ export const HaapiStepperWebAuthnRegistrationAttachmentCard = ({ - registrationAttachment, + action, disabled, onClick, }: HaapiStepperWebAuthnRegistrationAttachmentCardProps) => { - const { kind, title, description } = registrationAttachment; + const { currentStep } = useHaapiStepper(); + const { kind, title, description } = getAnyDeviceWebAuthnRegistrationAttachment( + action, + currentStep?.metadata?.viewData?.messages + ); return (