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
19 changes: 19 additions & 0 deletions src/haapi-react-app/src/shared/util/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,24 @@ svg {
@extend .center, .flex, .flex-column;
}

.haapi-stepper-bankid-qr-code-accessibility {
details + details {
@extend .mt2;
}

details[open] summary {
margin-bottom: var(--space-1);
}

details[open]::details-content {
padding-block-start: 0;
}

summary + * {
margin-block-start: 0;
}
}

.haapi-stepper-heading {
&:is(h1) {
font-size: calc(var(--type-base-size) * 1.75);
Expand All @@ -298,6 +316,7 @@ svg {
}

.haapi-stepper-polling-progress {
@extend .mt2;
border-radius: var(--form-field-border-radius);
background-color: var(--color-grey-subtle);
outline: 1px solid var(--color-grey-light);
Expand Down
1 change: 1 addition & 0 deletions src/haapi-react-sdk/haapi-stepper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ The Curity utility composition shown above is just how *this* project chose to i
| `.haapi-stepper-link-qr-code-dialog` | `HaapiStepperQrCodeLinkDialog` | Fullscreen QR code dialog |
| `.haapi-stepper-link-qr-code-dialog-close-button` | `HaapiStepperQrCodeLinkDialog` | Button wrapping the expanded QR code image; closes the dialog when clicked |
| `.haapi-stepper-link-qr-code-dialog-image` | `HaapiStepperQrCodeLinkDialog` | Fullscreen QR code dialog image |
| `.haapi-stepper-bankid-qr-code-accessibility` | `HaapiStepperBankIdQrCodeAccessibilityMessages` | Container for the BankID QR-code accessibility messages (the collapsible "help" and "screen reader" `<details>` sections) |
| `.haapi-stepper-actions` | `HaapiStepperActionsUI` | Actions container |
| `.haapi-stepper-heading` | `HaapiStepperMessagesUI` | Heading messages |
| `.haapi-stepper-userName` | `HaapiStepperMessagesUI` | User name display |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,15 @@ export interface HaapiLink {
title?: string;
}

/**
* View-specific data attached to the response. The shape depends on the view that produced it;
*/
Comment on lines +346 to +348
export interface HaapiViewData {
/** Map of message key to its localized text, as sent by the server for this view. */
messages?: Record<string, string>;
[key: string]: unknown;
}

/**
* Object with additional information about the response. A client may ignore the information present in this object.
*/
Expand All @@ -351,4 +360,6 @@ export interface HaapiMetadata {
templateArea?: string;
/** The name for the view that produced the response */
viewName?: string;
/** View-specific data (e.g. localized accessibility messages) for the view that produced the response */
viewData?: HaapiViewData;
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ import {
isWebAuthnRegistrationClientOperation,
splitWebAuthnRegistrationAction,
} from '../../actions/client-operation/operations/webauthn';
import { isQrCodeLink } from '../../../util/link-predicates';
import { getQrCodeAccessibility } from './qr-code-accessibility/qr-code-accessibility';

export function formatNextStepData<T extends HaapiActionStep | HaapiCompletedStep | HaapiStepperStep>(
step: T
): T & HaapiStepperDataHelpers {
const isStepWithoutActions =
step.type === HAAPI_STEPS.COMPLETED_WITH_SUCCESS || step.type === HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR;
const linksWithDataHelpers = step.links?.map(link => getElementWithDataHelpers(link)) ?? [];
const linksWithDataHelpers = step.links?.map(link => addLinkDataHelpers(link, step)) ?? [];
const messagesWithDataHelpers = step.messages?.map(message => getElementWithDataHelpers(message)) ?? [];
const dataHelpers = {
messages: messagesWithDataHelpers,
Expand All @@ -66,6 +68,20 @@ export function formatNextStepData<T extends HaapiActionStep | HaapiCompletedSte
};
}

function addLinkDataHelpers(
link: HaapiLink,
step: HaapiActionStep | HaapiCompletedStep | HaapiStepperStep
): HaapiStepperLink {
const linkWithDataHelpers = getElementWithDataHelpers(link);
const qrCodeAccessibility = getQrCodeAccessibility(step.metadata?.viewData?.messages);

if (qrCodeAccessibility && isQrCodeLink(linkWithDataHelpers)) {
return { ...linkWithDataHelpers, qrCodeAccessibility };
}

return linkWithDataHelpers;
}

function getNextStepActions(actions: HaapiAction[]): HaapiAction[] {
return actions.flatMap(action =>
isWebAuthnRegistrationClientOperation(action) ? splitWebAuthnRegistrationAction(action) : [action]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* 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 { describe, expect, it } from 'vitest';

import { getQrCodeAccessibility } from './qr-code-accessibility';

const PREFIX = 'authenticator.bankid.launch.view.qr.';
const key = (suffix: string) => `${PREFIX}${suffix}`;

const INSTRUCTION_MESSAGES: Record<string, string> = {
[key('instruction.heading')]: 'Help with scanning the QR code',
[key('instruction.step1')]: 'Open the BankID app',
[key('instruction.step2')]: 'Press the Scan QR code button',
[key('instruction.step3')]: "Point your phone's camera at the QR code",
[key('instruction.step4')]: 'Follow the instructions in the app',
[key('instruction.outro')]: 'The QR code is displayed for a configurable period.',
};

const SCREEN_READER_MESSAGES: Record<string, string> = {
[key('screen-reader.heading')]: 'If you are using a screen reader',
[key('screen-reader.intro')]: 'The most common error is that the full QR code is not visible. Try to:',
[key('screen-reader.step1')]: 'Ensure the screen is on',
[key('screen-reader.step2')]: 'Zoom out in the browser',
[key('screen-reader.step3')]: 'Zoom out using magnification tools',
[key('screen-reader.step4')]: 'Make sure the browser window is maximized by:',
[key('screen-reader.step4.1')]: 'Clicking on the QR code above or',
[key('screen-reader.step4.2')]: 'Using keyboard shortcuts',
[key('screen-reader.step4.2.1')]: 'Windows: Ctrl+Arrow up',
[key('screen-reader.step4.2.2')]: 'Mac: Ctrl+Cmd+F',
[key('screen-reader.outro')]: 'Hold the phone in portrait mode about 40 cm away from the screen.',
};

describe('getQrCodeAccessibility', () => {
it('builds both sections from a complete message set', () => {
const result = getQrCodeAccessibility({ ...INSTRUCTION_MESSAGES, ...SCREEN_READER_MESSAGES });

expect(result).toEqual({
instruction: {
heading: 'Help with scanning the QR code',
items: [
{ text: 'Open the BankID app' },
{ text: 'Press the Scan QR code button' },
{ text: "Point your phone's camera at the QR code" },
{ text: 'Follow the instructions in the app' },
],
outro: 'The QR code is displayed for a configurable period.',
},
screenReader: {
heading: 'If you are using a screen reader',
intro: 'The most common error is that the full QR code is not visible. Try to:',
items: [
{ text: 'Ensure the screen is on' },
{ text: 'Zoom out in the browser' },
{ text: 'Zoom out using magnification tools' },
{
text: 'Make sure the browser window is maximized by:',
items: [
{ text: 'Clicking on the QR code above or' },
{
text: 'Using keyboard shortcuts',
items: [{ text: 'Windows: Ctrl+Arrow up' }, { text: 'Mac: Ctrl+Cmd+F' }],
},
],
},
],
outro: 'Hold the phone in portrait mode about 40 cm away from the screen.',
},
});
});

it('returns undefined when there are no messages', () => {
expect(getQrCodeAccessibility(undefined)).toBeUndefined();
});

it('builds only the section whose messages are present', () => {
const result = getQrCodeAccessibility(INSTRUCTION_MESSAGES);

expect(result?.instruction).toBeDefined();
expect(result?.screenReader).toBeUndefined();
});

it('omits a section whose message set is incomplete', () => {
const incompleteInstructions = Object.fromEntries(
Object.entries(INSTRUCTION_MESSAGES).filter(([messageKey]) => messageKey !== key('instruction.outro'))
);

expect(getQrCodeAccessibility(incompleteInstructions)).toBeUndefined();
});

it('resolves messages by the ".view.qr." suffix regardless of the key prefix', () => {
const waitPrefixed = Object.fromEntries(
Object.entries(INSTRUCTION_MESSAGES).map(([messageKey, value]) => [
messageKey.replace('.launch.view.qr.', '.wait.view.qr.'),
value,
])
);

expect(getQrCodeAccessibility(waitPrefixed)?.instruction?.heading).toBe('Help with scanning the QR code');
});

it('ignores keys without the ".view.qr." marker', () => {
const result = getQrCodeAccessibility({
...INSTRUCTION_MESSAGES,
'authenticator.bankid.launch.page.title': 'Login with BankID',
});

expect(result?.instruction).toBeDefined();
expect(JSON.stringify(result)).not.toContain('Login with BankID');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* 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 { HaapiStepperQrCodeAccessibility, HaapiStepperQrCodeAccessibilitySection } from '../../haapi-stepper.types';

/**
* Resolves the BankID QR-code accessibility copy carried in `metadata.viewData.messages` into a
* render-ready structure. Returns `undefined` when neither section is complete, so consumers receive
* ready-shaped data without parsing message keys or checking completeness themselves.
*/
export function getQrCodeAccessibility(messages?: Record<string, string>): HaapiStepperQrCodeAccessibility | undefined {
const qr = stripQrCodeViewPrefix(messages);
const instruction = buildInstructionSection(qr);
const screenReader = buildScreenReaderSection(qr);

if (!instruction && !screenReader) {
return undefined;
}

return {
...(instruction && { instruction }),
...(screenReader && { screenReader }),
};
}

/**
* Normalizes the raw `metadata.viewData.messages` map (keys like
* `authenticator.bankid.launch.view.qr.instruction.heading`) into a map keyed by the logical suffix
* after `.view.qr.` (e.g. `instruction.heading`, `screen-reader.step4.2.1`). Keying by the suffix
* keeps this independent of the message-key prefix; keys without the marker are ignored.
*/
function stripQrCodeViewPrefix(messages?: Record<string, string>): Record<string, string> {
const QR_CODE_VIEW_MARKER = '.view.qr.';

return Object.fromEntries(
Object.entries(messages ?? {})
.filter(([key]) => key.includes(QR_CODE_VIEW_MARKER))
.map(([key, value]) => [key.slice(key.lastIndexOf(QR_CODE_VIEW_MARKER) + QR_CODE_VIEW_MARKER.length), value])
);
}

const allPresent = (qr: Record<string, string>, keys: string[]): boolean =>
keys.every(key => typeof qr[key] === 'string' && qr[key].length > 0);

function buildInstructionSection(qr: Record<string, string>): HaapiStepperQrCodeAccessibilitySection | undefined {
if (
!allPresent(qr, [
'instruction.heading',
'instruction.step1',
'instruction.step2',
'instruction.step3',
'instruction.step4',
'instruction.outro',
])
) {
return undefined;
}
return {
heading: qr['instruction.heading'],
items: [qr['instruction.step1'], qr['instruction.step2'], qr['instruction.step3'], qr['instruction.step4']].map(
text => ({ text })
),
outro: qr['instruction.outro'],
};
}

function buildScreenReaderSection(qr: Record<string, string>): HaapiStepperQrCodeAccessibilitySection | undefined {
if (
!allPresent(qr, [
'screen-reader.heading',
'screen-reader.intro',
'screen-reader.step1',
'screen-reader.step2',
'screen-reader.step3',
'screen-reader.step4',
'screen-reader.step4.1',
'screen-reader.step4.2',
'screen-reader.step4.2.1',
'screen-reader.step4.2.2',
'screen-reader.outro',
])
) {
return undefined;
}
return {
heading: qr['screen-reader.heading'],
intro: qr['screen-reader.intro'],
items: [
{ text: qr['screen-reader.step1'] },
{ text: qr['screen-reader.step2'] },
{ text: qr['screen-reader.step3'] },
{
text: qr['screen-reader.step4'],
items: [
{ text: qr['screen-reader.step4.1'] },
{
text: qr['screen-reader.step4.2'],
items: [{ text: qr['screen-reader.step4.2.1'] }, { text: qr['screen-reader.step4.2.2'] }],
},
],
},
],
outro: qr['screen-reader.outro'],
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,15 @@ export type HaapiStepperUserMessage = HaapiUserMessage &
/*
* STEP LINK TYPINGS
*/
export type HaapiStepperLink = HaapiLink & HaapiStepperDataHelpersDetails<HAAPI_STEPPER_ELEMENT_TYPES.LINK>;
export type HaapiStepperLink = HaapiLink &
HaapiStepperDataHelpersDetails<HAAPI_STEPPER_ELEMENT_TYPES.LINK> & {
/**
* Render-ready BankID QR-code accessibility copy, resolved from `metadata.viewData.messages`
* during step-data formatting. Present only on the QR-code link when the server supplied a
* complete section.
*/
qrCodeAccessibility?: HaapiStepperQrCodeAccessibility;
};

/*
* ERROR TYPINGS
Expand Down Expand Up @@ -257,6 +265,30 @@ export interface HaapiStepperDataHelpers {
links: HaapiStepperLink[];
};
}

/** QR-code accessibility copy, structured for direct rendering. */
export interface HaapiStepperQrCodeAccessibility {
instruction?: HaapiStepperQrCodeAccessibilitySection;
screenReader?: HaapiStepperQrCodeAccessibilitySection;
}

/** One collapsible QR-code accessibility section (instruction / screen-reader). */
export interface HaapiStepperQrCodeAccessibilitySection {
heading: string;
intro?: string;
items: HaapiStepperQrCodeAccessibilityItem[];
outro: string;
}

/**
* A QR-code accessibility list entry, optionally nesting sub-entries (the screen-reader section is
* a nested list).
*/
export interface HaapiStepperQrCodeAccessibilityItem {
text: string;
items?: HaapiStepperQrCodeAccessibilityItem[];
}

export type HaapiStepperDataHelpersDetails<
T,
ST extends T extends HAAPI_STEPPER_ELEMENT_TYPES.ACTION
Expand Down
Loading
Loading