Skip to content

Commit a13e619

Browse files
aleixsuauclaude
andcommitted
IS-11495 Render WebAuthn registration attachment options as selection cards
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d232f2f commit a13e619

16 files changed

Lines changed: 668 additions & 12 deletions

src/haapi-react-app/src/shared/util/css/styles.css

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,54 @@ svg {
297297
}
298298
}
299299

300+
/* WebAuthn registration attachment-selection option cards */
301+
.haapi-stepper-webauthn-registration-attachment {
302+
display: flex;
303+
align-items: center;
304+
gap: var(--space-3);
305+
width: 100%;
306+
margin-block-start: var(--space-2);
307+
padding: var(--space-3);
308+
text-align: left;
309+
background-color: transparent;
310+
color: var(--color-text);
311+
border: var(--form-field-border-width) var(--form-field-border-style) var(--form-field-border-color);
312+
border-radius: var(--form-field-border-radius);
313+
cursor: pointer;
314+
transition: color 200ms, background-color 200ms, border-color 200ms;
315+
316+
/* Match the corporate `.button-primary-outline` hover: fill with the brand colour, invert text. */
317+
&:hover,
318+
&:focus-visible {
319+
background-color: var(--button-color);
320+
border-color: var(--button-color);
321+
color: white;
322+
323+
.haapi-stepper-webauthn-registration-attachment-icon {
324+
color: white;
325+
}
326+
}
327+
}
328+
329+
.haapi-stepper-webauthn-registration-attachment-icon {
330+
display: inline-flex;
331+
flex-shrink: 0;
332+
width: 48px;
333+
height: 48px;
334+
/* Brand blue-grey of the source artwork; the icons use currentColor so this can be overridden. */
335+
color: #626c87;
336+
337+
svg {
338+
width: 100%;
339+
height: 100%;
340+
}
341+
}
342+
343+
.haapi-stepper-webauthn-registration-attachment-title {
344+
font-family: var(--type-bold);
345+
margin-inline-end: var(--space-2);
346+
}
347+
300348
.haapi-stepper-heading {
301349
&:is(h1) {
302350
font-size: calc(var(--type-base-size) * 1.75);

src/haapi-react-sdk/haapi-stepper/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,9 @@ Check out [the HaapiStepperStepUI documentation and usage examples](./feature/st
147147

148148
### ViewName built-in UIs
149149

150-
The HaapiStepperStepUI ships built-in UIs for specific HAAPI `viewName`s (`step.metadata.viewName`) that need a more tailored UI than the generic step shell can provide (e.g. the BankID requires the QR link to be lifted above the actions). They are displayed by default and can be customized like any other step by using render interceptors.
150+
The HaapiStepperStepUI ships built-in UIs for specific HAAPI `viewName`s (`step.metadata.viewName`) that need a more tailored UI than the generic step shell can provide (e.g. the BankID requires the QR link to be lifted above the actions).
151+
152+
ViewName built-in UIs operate at the **step** level: they reshape the whole step for a given `viewName`. They are displayed by default and can be customized like any other step by using render interceptors.
151153

152154
Check out documentation and usage examples in [`HaapiStepperStepUI`](./feature/steps/HaapiStepperStepUI.tsx), and the test use cases in [`HaapiStepperStepUI.spec.tsx`](./feature/steps/HaapiStepperStepUI.spec.tsx) (`describe('ViewName built-in UIs Rendering')`) for more details.
153155

@@ -237,6 +239,10 @@ The Curity utility composition shown above is just how *this* project chose to i
237239
| `.haapi-stepper-userName` | `HaapiStepperMessagesUI` | User name display |
238240
| `.haapi-stepper-userCode` | `HaapiStepperMessagesUI` | User code display (e.g. recovery codes) |
239241
| `.haapi-stepper-polling-progress` | `HaapiStepperClientOperationUI` | Remaining polling time indicator (e.g. recovery codes) |
242+
| `.haapi-stepper-webauthn-registration-attachment` | `HaapiStepperWebAuthnRegistrationAttachmentCard` | WebAuthn registration attachment-selection option card (icon + title + description) |
243+
| `.haapi-stepper-webauthn-registration-attachment-icon` | `HaapiStepperWebAuthnRegistrationAttachmentCard` | Attachment card icon |
244+
| `.haapi-stepper-webauthn-registration-attachment-title` | `HaapiStepperWebAuthnRegistrationAttachmentCard` | Attachment card option label |
245+
| `.haapi-stepper-webauthn-registration-attachment-description` | `HaapiStepperWebAuthnRegistrationAttachmentCard` | Attachment card option description |
240246
| `.haapi-stepper-error-boundary-fallback` | `DefaultErrorFallback` | Error boundary fallback container |
241247
| `.haapi-validation-errors-container` | `HaapiStepperFormValidationErrorInputWrapper` | Wrapper around a form field that has validation errors. Receives the `.has-errors` modifier class while errors are visible |
242248
| `.haapi-validation-errors` | `HaapiStepperFormValidationErrorInputWrapper` | Inner container that holds the list of validation error messages |

src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
createMockExternalBrowserFlowAction,
2020
createMockStep,
2121
createMockWebAuthnAnyDeviceBothOptionsAction,
22+
createMockWebAuthnCrossPlatformOnlyAnyDeviceAction,
2223
createMockWebAuthnPlatformOnlyAnyDeviceAction,
2324
createMockWebAuthnRegistrationAction,
2425
externalBrowserFlowActionTitle,
@@ -28,15 +29,22 @@ import {
2829
} from '../../../util/tests/mocks';
2930
import { HaapiStepperActionsUI } from '../../../ui/actions/HaapiStepperActionsUI';
3031
import { HaapiStepperClientOperationUI } from './HaapiStepperClientOperationUI';
31-
import { useIsWebAuthnPlatformAuthenticatorAvailable } from './operations/webauthn';
32-
33-
vi.mock('./operations/webauthn', async () => {
34-
const actual = await vi.importActual('./operations/webauthn');
35-
return {
36-
...(actual as object),
37-
useIsWebAuthnPlatformAuthenticatorAvailable: vi.fn(() => undefined),
38-
};
39-
});
32+
import { useIsWebAuthnPlatformAuthenticatorAvailable } from './operations/webauthn/useIsWebAuthnPlatformAuthenticatorAvailable';
33+
import { WebAuthnRegistrationAttachmentKind } from '../../stepper/haapi-stepper.types';
34+
35+
const PLATFORM_TITLE = 'Built-in';
36+
const PLATFORM_DESCRIPTION = 'A non-removable built-in device.';
37+
const PLATFORM_ICON_VIEW_BOX = '0 0 64.2 83.9';
38+
const CROSS_PLATFORM_TITLE = 'Security key';
39+
const CROSS_PLATFORM_DESCRIPTION =
40+
'A security key, such as a portable key attached through USB or wirelessly through NFC.';
41+
const CROSS_PLATFORM_ICON_VIEW_BOX = '0 0 49 72.6';
42+
const REGISTER_VIEW_NAME = 'authenticator/webauthn/register/get';
43+
const MESSAGE_PREFIX = 'authenticator.webauthn.register.view.';
44+
45+
vi.mock('./operations/webauthn/useIsWebAuthnPlatformAuthenticatorAvailable', () => ({
46+
useIsWebAuthnPlatformAuthenticatorAvailable: vi.fn(() => undefined),
47+
}));
4048

4149
describe('HaapiStepperClientOperationUI', () => {
4250
let user: ReturnType<typeof userEvent.setup>;
@@ -141,6 +149,76 @@ describe('HaapiStepperClientOperationUI', () => {
141149
screen.getByRole('button', { name: `${webAuthnAnyDeviceActionTitle} (Security key)` })
142150
).toBeInTheDocument();
143151
});
152+
153+
describe('WebAuthn registration attachment card (any device mode (platform/cross-platform))', () => {
154+
it.each([
155+
{
156+
name: 'platform',
157+
makeAction: createMockWebAuthnPlatformOnlyAnyDeviceAction,
158+
kind: WebAuthnRegistrationAttachmentKind.PLATFORM,
159+
title: PLATFORM_TITLE,
160+
description: PLATFORM_DESCRIPTION,
161+
iconViewBox: PLATFORM_ICON_VIEW_BOX,
162+
},
163+
{
164+
name: 'cross-platform',
165+
makeAction: createMockWebAuthnCrossPlatformOnlyAnyDeviceAction,
166+
kind: WebAuthnRegistrationAttachmentKind.CROSS_PLATFORM,
167+
title: CROSS_PLATFORM_TITLE,
168+
description: CROSS_PLATFORM_DESCRIPTION,
169+
iconViewBox: CROSS_PLATFORM_ICON_VIEW_BOX,
170+
},
171+
])(
172+
'renders the $name attachment option as a card (title, description and matching icon)',
173+
({ makeAction, kind, title, description, iconViewBox }) => {
174+
const action = { ...makeAction(), webauthn: { registrationAttachment: { kind, title, description } } };
175+
176+
const { container } = render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} />);
177+
178+
expect(screen.getByTestId('webauthn-registration-attachment-card')).toBeInTheDocument();
179+
expect(screen.getByText(title)).toBeInTheDocument();
180+
expect(screen.getByText(description)).toBeInTheDocument();
181+
expect(container.querySelector('.haapi-stepper-webauthn-registration-attachment-icon svg')).toHaveAttribute(
182+
'viewBox',
183+
iconViewBox
184+
);
185+
}
186+
);
187+
188+
it('renders one card per option when a both-options any-device registration supplies messages', () => {
189+
const step = createMockStep(HAAPI_STEPS.REGISTRATION, {
190+
actions: [createMockWebAuthnAnyDeviceBothOptionsAction()],
191+
metadata: {
192+
viewName: REGISTER_VIEW_NAME,
193+
viewData: {
194+
messages: {
195+
[`${MESSAGE_PREFIX}button.platform`]: PLATFORM_TITLE,
196+
[`${MESSAGE_PREFIX}button.cross-platform`]: CROSS_PLATFORM_TITLE,
197+
[`${MESSAGE_PREFIX}authenticator-attachment.platform`]: PLATFORM_DESCRIPTION,
198+
[`${MESSAGE_PREFIX}authenticator-attachment.cross-platform`]: CROSS_PLATFORM_DESCRIPTION,
199+
},
200+
},
201+
},
202+
});
203+
204+
render(<HaapiStepperActionsUI actions={step.dataHelpers.actions?.all} onAction={vi.fn()} />);
205+
206+
// The both-options action is split upstream into two single-option actions, so it renders as
207+
// two separate cards — the component itself never receives both attachments at once.
208+
expect(screen.getAllByTestId('webauthn-registration-attachment-card')).toHaveLength(2);
209+
expect(screen.getByText(PLATFORM_TITLE)).toBeInTheDocument();
210+
expect(screen.getByText(CROSS_PLATFORM_TITLE)).toBeInTheDocument();
211+
});
212+
213+
it('renders the default button (no card) when the action has no attachment copy', () => {
214+
render(
215+
<HaapiStepperClientOperationUI action={createMockWebAuthnPlatformOnlyAnyDeviceAction()} onAction={vi.fn()} />
216+
);
217+
218+
expect(screen.queryByTestId('webauthn-registration-attachment-card')).not.toBeInTheDocument();
219+
expect(screen.getByTestId('client-operation-action')).toBeInTheDocument();
220+
});
221+
});
144222
});
145223
});
146224

src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111

1212
import { HaapiStepperClientOperationAction, HaapiStepperFormAction } from '../../stepper/haapi-stepper.types';
1313
import { useIsClientOperationAvailable } from './useIsClientOperationAvailable';
14+
import {
15+
HaapiStepperWebAuthnRegistrationAttachmentCard,
16+
isAnyDeviceWebAuthnRegistrationClientOperation,
17+
} from './operations/webauthn';
1418

1519
interface HaapiStepperClientOperationUIProps {
1620
action: HaapiStepperClientOperationAction;
@@ -50,6 +54,16 @@ export function HaapiStepperClientOperationUI({
5054
}: HaapiStepperClientOperationUIProps) {
5155
const isAvailable = useIsClientOperationAvailable(action);
5256

57+
if (isAnyDeviceWebAuthnRegistrationClientOperation(action) && action.webauthn?.registrationAttachment) {
58+
return (
59+
<HaapiStepperWebAuthnRegistrationAttachmentCard
60+
action={action}
61+
disabled={!isAvailable}
62+
onClick={() => onAction(action)}
63+
/>
64+
);
65+
}
66+
5367
return (
5468
<div data-testid="client-operation-action">
5569
{showBankIdSessionTimeLeft && action.maxWaitRemainingTime !== undefined && (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright (C) 2026 Curity AB. All rights reserved.
3+
*
4+
* The contents of this file are the property of Curity AB.
5+
* You may not copy or use this file, in either source code
6+
* or executable form, except in compliance with terms
7+
* set by Curity AB.
8+
*
9+
* For further information, please contact Curity AB.
10+
*/
11+
12+
import { render, screen } from '@testing-library/react';
13+
import userEvent from '@testing-library/user-event';
14+
import { describe, expect, it, vi } from 'vitest';
15+
16+
import { createMockWebAuthnPlatformOnlyAnyDeviceAction } from '../../../../../util/tests/mocks';
17+
import {
18+
HaapiStepperWebAuthnAnyDeviceRegistrationAction,
19+
HaapiWebAuthnRegistrationAttachment,
20+
WebAuthnRegistrationAttachmentKind,
21+
} from '../../../../stepper/haapi-stepper.types';
22+
import { HaapiStepperWebAuthnRegistrationAttachmentCard } from './HaapiStepperWebAuthnRegistrationAttachmentCard';
23+
24+
const PLATFORM_TITLE = 'Built-in';
25+
const PLATFORM_DESCRIPTION = 'A non-removable built-in device.';
26+
const PLATFORM_ICON_VIEW_BOX = '0 0 64.2 83.9';
27+
const CROSS_PLATFORM_TITLE = 'Security key';
28+
const CROSS_PLATFORM_ICON_VIEW_BOX = '0 0 49 72.6';
29+
30+
const actionWith = (
31+
registrationAttachment?: HaapiWebAuthnRegistrationAttachment
32+
): HaapiStepperWebAuthnAnyDeviceRegistrationAction => ({
33+
...createMockWebAuthnPlatformOnlyAnyDeviceAction(),
34+
webauthn: registrationAttachment ? { registrationAttachment } : undefined,
35+
});
36+
37+
describe('HaapiStepperWebAuthnRegistrationAttachmentCard', () => {
38+
it('renders the title, description and icon resolved from the action attachment', () => {
39+
const { container } = render(
40+
<HaapiStepperWebAuthnRegistrationAttachmentCard
41+
action={actionWith({
42+
kind: WebAuthnRegistrationAttachmentKind.PLATFORM,
43+
title: PLATFORM_TITLE,
44+
description: PLATFORM_DESCRIPTION,
45+
})}
46+
onClick={vi.fn()}
47+
/>
48+
);
49+
50+
expect(screen.getByText(PLATFORM_TITLE)).toBeInTheDocument();
51+
expect(screen.getByText(PLATFORM_DESCRIPTION)).toBeInTheDocument();
52+
expect(container.querySelector('.haapi-stepper-webauthn-registration-attachment-icon svg')).toHaveAttribute(
53+
'viewBox',
54+
PLATFORM_ICON_VIEW_BOX
55+
);
56+
});
57+
58+
it('omits the description when the attachment has none, still rendering the icon', () => {
59+
const { container } = render(
60+
<HaapiStepperWebAuthnRegistrationAttachmentCard
61+
action={actionWith({ kind: WebAuthnRegistrationAttachmentKind.CROSS_PLATFORM, title: CROSS_PLATFORM_TITLE })}
62+
onClick={vi.fn()}
63+
/>
64+
);
65+
66+
const card = screen.getByTestId('webauthn-registration-attachment-card');
67+
expect(screen.getByText(CROSS_PLATFORM_TITLE)).toBeInTheDocument();
68+
expect(card.querySelector('.haapi-stepper-webauthn-registration-attachment-description')).toBeNull();
69+
expect(container.querySelector('.haapi-stepper-webauthn-registration-attachment-icon svg')).toHaveAttribute(
70+
'viewBox',
71+
CROSS_PLATFORM_ICON_VIEW_BOX
72+
);
73+
});
74+
75+
it('renders nothing when the action carries no attachment', () => {
76+
render(<HaapiStepperWebAuthnRegistrationAttachmentCard action={actionWith()} onClick={vi.fn()} />);
77+
78+
expect(screen.queryByTestId('webauthn-registration-attachment-card')).not.toBeInTheDocument();
79+
});
80+
81+
it('calls onClick when the card is clicked', async () => {
82+
const user = userEvent.setup();
83+
const onClick = vi.fn();
84+
render(
85+
<HaapiStepperWebAuthnRegistrationAttachmentCard
86+
action={actionWith({ kind: WebAuthnRegistrationAttachmentKind.PLATFORM, title: PLATFORM_TITLE })}
87+
onClick={onClick}
88+
/>
89+
);
90+
91+
await user.click(screen.getByTestId('webauthn-registration-attachment-card'));
92+
expect(onClick).toHaveBeenCalledOnce();
93+
});
94+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright (C) 2026 Curity AB. All rights reserved.
3+
*
4+
* The contents of this file are the property of Curity AB.
5+
* You may not copy or use this file, in either source code
6+
* or executable form, except in compliance with terms
7+
* set by Curity AB.
8+
*
9+
* For further information, please contact Curity AB.
10+
*/
11+
12+
import { HaapiStepperWebAuthnAnyDeviceRegistrationAction } from '../../../../stepper/haapi-stepper.types';
13+
import { REGISTRATION_ATTACHMENT_ICON } from './webauthn-registration-attachment-icon-map';
14+
15+
export interface HaapiStepperWebAuthnRegistrationAttachmentCardProps {
16+
/** The any-device `webauthn-registration` action this option advances the flow with. */
17+
action: HaapiStepperWebAuthnAnyDeviceRegistrationAction;
18+
/** Disables the card (e.g. when the required authenticator capability is unavailable). */
19+
disabled?: boolean;
20+
onClick: () => void;
21+
}
22+
23+
/**
24+
* A single WebAuthn registration attachment-selection option, rendered as a clickable card
25+
* (icon + bold title + description). Resolves the localized copy and icon from the action's
26+
* `attachment` and wires `onClick` to advance the flow.
27+
*
28+
* Exported so consumers building a custom WebAuthn registration UI can reuse it.
29+
*/
30+
export const HaapiStepperWebAuthnRegistrationAttachmentCard = ({
31+
action,
32+
disabled,
33+
onClick,
34+
}: HaapiStepperWebAuthnRegistrationAttachmentCardProps) => {
35+
const registrationAttachment = action.webauthn?.registrationAttachment;
36+
if (!registrationAttachment) {
37+
return null;
38+
}
39+
40+
return (
41+
<button
42+
type="button"
43+
className="haapi-stepper-webauthn-registration-attachment"
44+
data-testid="webauthn-registration-attachment-card"
45+
disabled={disabled}
46+
onClick={onClick}
47+
>
48+
<span className="haapi-stepper-webauthn-registration-attachment-icon" aria-hidden="true">
49+
{REGISTRATION_ATTACHMENT_ICON[registrationAttachment.kind]}
50+
</span>
51+
<span className="haapi-stepper-webauthn-registration-attachment-text">
52+
<strong className="haapi-stepper-webauthn-registration-attachment-title">{registrationAttachment.title}</strong>
53+
{registrationAttachment.description && (
54+
<span className="haapi-stepper-webauthn-registration-attachment-description">
55+
{registrationAttachment.description}
56+
</span>
57+
)}
58+
</span>
59+
</button>
60+
);
61+
};

src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/operations/webauthn/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ export * from './webauthn';
1313
export * from './utils';
1414
export * from './useIsWebAuthnPlatformAuthenticatorAvailable';
1515
export * from './auto-start';
16+
export * from './HaapiStepperWebAuthnRegistrationAttachmentCard';
17+
export * from './webauthn-registration-attachment-icons';
18+
export * from './webauthn-registration-attachment-icon-map';

0 commit comments

Comments
 (0)