From 1432273e101061230cb78f125d9f2b1317aac346 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 28 May 2026 16:18:32 +0200 Subject: [PATCH 01/15] IS-11346: render page symbols for HAAPI steps Adds the LWA-side rendering of `theme.pageSymbols`. The `PageSymbol` component resolves the symbol URL for the current step's HAAPI `viewName` against the bootstrap configuration's `pageSymbols` map: 1. exact match in `views[viewName]` 2. plugin-type match in `plugins[]` (extracted from the `authenticator | authentication-action | consentor` viewName format) 3. fallback to `default` 4. otherwise renders nothing Wired into `HaapiStepperStepUI` so the symbol renders above the step's messages/actions/links, with a slot in `ViewNameBuiltInUIProps` so the BankID built-in places it above the lifted QR link. --- .../data-access/bootstrap-configuration.ts | 14 ++ .../feature/steps/HaapiStepperStepUI.spec.tsx | 68 +++++++++- .../feature/steps/HaapiStepperStepUI.tsx | 5 + .../viewnames/BankIdViewNameBuiltInUI.tsx | 11 +- .../feature/viewnames/typings.ts | 1 + .../src/shared/ui/PageSymbol.spec.tsx | 121 ++++++++++++++++++ .../src/shared/ui/PageSymbol.tsx | 35 +++++ .../src/shared/util/css/styles.css | 11 ++ .../src/shared/util/resolve-page-symbol.ts | 53 ++++++++ 9 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 src/login-web-app/src/shared/ui/PageSymbol.spec.tsx create mode 100644 src/login-web-app/src/shared/ui/PageSymbol.tsx create mode 100644 src/login-web-app/src/shared/util/resolve-page-symbol.ts diff --git a/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts b/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts index 34b64bf8..cf697d8c 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts @@ -19,9 +19,23 @@ export interface BootstrapConfiguration { path: string; isInsideWell: boolean; }; + /** + * Optional per-page icon configuration. Only present when symbols are enabled in the server theme. + * Resolved against the current step's `metadata.viewName` (see `resolvePageSymbol`). + */ + pageSymbols?: PageSymbols; }; } +export interface PageSymbols { + /** Map of full HAAPI viewName -> symbol path. Highest precedence. */ + views?: Record; + /** Map of plugin implementation type (e.g. `html-form`, `bankid`) -> symbol path. */ + plugins?: Record; + /** Fallback symbol path used when no per-view / per-plugin entry matches. */ + default?: string; +} + // @ts-expect-error window.__CONFIG__ is not declared on the Window type const _configuration = window.__CONFIG__ as BootstrapConfiguration | undefined; if (!_configuration) { diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index 0dfe85b6..2b866ee1 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -14,6 +14,11 @@ import { useEffect } from 'react'; import userEvent from '@testing-library/user-event'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { HaapiStepperContext } from '../stepper/HaapiStepperContext'; +import { AppConfigContext } from '../../../shared/feature/app-config/AppConfigContext'; +import type { + BootstrapConfiguration, + PageSymbols, +} from '../../data-access/bootstrap-configuration'; import type { HaapiStepperAPI, HaapiStepperNextStep, @@ -67,13 +72,34 @@ import { } from '../../util/tests/mocks'; import { HaapiStepperFormFieldUI } from '../actions/form/fields/HaapiStepperFormFieldUI'; -const renderWithContext = (ui: React.ReactElement, contextValue: Partial = {}) => { +const buildAppConfig = (pageSymbols?: PageSymbols): BootstrapConfiguration => ({ + initialUrl: 'https://example/start', + haapi: {} as BootstrapConfiguration['haapi'], + theme: { + logo: { path: '/assets/logo.svg', isInsideWell: false }, + pageSymbols, + }, +}); + +/** Returns true when `a` appears before `b` in document order. */ +const rendersBefore = (a: Element, b: Element): boolean => + !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + +const renderWithContext = ( + ui: React.ReactElement, + contextValue: Partial = {}, + pageSymbols?: PageSymbols +) => { const value: HaapiStepperAPI = { ...defaultStepperAPI, ...contextValue, }; - return render({ui}); + return render( + + {ui} + + ); }; describe('HaapiStepperStepUI', () => { @@ -2022,4 +2048,42 @@ describe('HaapiStepperStepUI', () => { }); }); }); + + describe('Page symbol', () => { + it('renders the resolved symbol above the messages/actions/links for the current step', () => { + const step = createMockStep(HAAPI_STEPS.AUTHENTICATION, { + metadata: { templateArea: 'lwa', viewName: 'authenticator/html-form/index' }, + }); + + renderWithContext(, { currentStep: step }, { + plugins: { 'html-form': '/symbols/html-form.svg' }, + }); + + const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; + const messages = screen.getByTestId('messages'); + + expect(pageSymbol).toHaveAttribute('src', '/symbols/html-form.svg'); + expect(rendersBefore(pageSymbol, messages)).toBe(true); + }); + + it('renders nothing when theme.pageSymbols is absent', () => { + renderWithContext(); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders the page symbol above the BankID QR link in the BankID built-in UI', () => { + const qrLink = createMockQrLink(); + const step = createPollingStep({ links: [qrLink] }); + + renderWithContext(, { currentStep: step }, { + plugins: { bankid: '/symbols/bankid.svg' }, + }); + + const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; + const qrButton = screen.getByTestId('qr-code-button'); + + expect(pageSymbol).toHaveAttribute('src', '/symbols/bankid.svg'); + expect(rendersBefore(pageSymbol, qrButton)).toBe(true); + }); + }); }); diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx index d8bde513..6a471b2e 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx @@ -14,6 +14,7 @@ import { formatNextStepData } from '../stepper/data-formatters/format-next-step- import { getViewNameBuiltInUI } from '../viewnames'; import type { HaapiStepperAPIWithRequiredCurrentStep } from '../stepper/haapi-stepper.types'; import { useHaapiStepper } from '../stepper/HaapiStepperHook'; +import { PageSymbol } from '../../../shared/ui/PageSymbol'; import { getActionsElement, getErrorElement, @@ -259,6 +260,8 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => { const linksToDisplay = getLinksToDisplay(error, currentStep); const messagesToDisplay = error?.input ? error.input.dataHelpers.messages : currentStep.dataHelpers.messages; + const pageSymbolElement = ; + const stepElements = { loadingElement, errorElement: getErrorElement(haapiStepperUiAPI, errorRenderInterceptor), @@ -272,6 +275,7 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => { clientOperationActionRenderInterceptor ), linksElement: getLinksElement(haapiStepperUiAPI, linksToDisplay, linkRenderInterceptor), + pageSymbolElement, }; const ViewNameBuiltInUI = getViewNameBuiltInUI(haapiStepperUiAPI); @@ -282,6 +286,7 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => { return ( <> + {stepElements.pageSymbolElement} {stepElements.loadingElement} {stepElements.errorElement} {stepElements.messagesElement} diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx index 26cb897d..c458c72b 100644 --- a/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx @@ -19,13 +19,22 @@ import type { ViewNameBuiltInUIProps } from './typings'; * - Lifts the QR code link above the actions so it's the primary element on the screen. */ export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => { - const { currentStep, linkRenderInterceptor, loadingElement, errorElement, messagesElement, actionsElement } = props; + const { + currentStep, + linkRenderInterceptor, + loadingElement, + errorElement, + messagesElement, + actionsElement, + pageSymbolElement, + } = props; const { links } = currentStep.dataHelpers; const qrLink = links.find(isQrCodeLink); const nonQrLinks = links.filter(link => !isQrCodeLink(link)); return ( <> + {pageSymbolElement} {loadingElement} {errorElement} {messagesElement} diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts b/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts index 9625a148..3596e333 100644 --- a/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts @@ -26,4 +26,5 @@ export type ViewNameBuiltInUIProps = HaapiStepperAPIWithRequiredCurrentStep & messagesElement: ReactElement; actionsElement: ReactElement | null; linksElement: ReactElement; + pageSymbolElement: ReactElement; }; diff --git a/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx b/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx new file mode 100644 index 00000000..7b63e1aa --- /dev/null +++ b/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx @@ -0,0 +1,121 @@ +/* + * 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 { render } from '@testing-library/react'; +import { PageSymbol } from './PageSymbol'; +import { AppConfigContext } from '../feature/app-config/AppConfigContext'; +import type { BootstrapConfiguration, PageSymbols } from '../../haapi-stepper/data-access/bootstrap-configuration'; + +const buildConfig = (pageSymbols?: PageSymbols): BootstrapConfiguration => ({ + initialUrl: 'https://example/start', + haapi: {} as BootstrapConfiguration['haapi'], + theme: { + logo: { path: '/assets/logo.svg', isInsideWell: false }, + pageSymbols, + }, +}); + +const renderPageSymbol = (viewName: string | undefined, pageSymbols?: PageSymbols) => + render( + + + + ); + +describe('PageSymbol', () => { + const pageSymbols: PageSymbols = { + views: { + 'authenticator/html-form/create-account/post': '/symbols/create-account.svg', + 'authentication-action/email-verifier/confirm': '/symbols/email-verifier-confirm.svg', + 'consentor/scope-consent/review': '/symbols/scope-consent-review.svg', + }, + plugins: { + 'html-form': '/symbols/html-form.svg', + 'email-verifier': '/symbols/email-verifier.svg', + 'scope-consent': '/symbols/scope-consent.svg', + }, + default: '/symbols/default.svg', + }; + + it.each([ + ['authenticator', 'authenticator/html-form/create-account/post', '/symbols/create-account.svg'], + ['authentication-action', 'authentication-action/email-verifier/confirm', '/symbols/email-verifier-confirm.svg'], + ['consentor', 'consentor/scope-consent/review', '/symbols/scope-consent-review.svg'], + ])( + 'renders the exact `views` entry for the %s category even when a plugin or default would also match', + (_, viewName, expected) => { + renderPageSymbol(viewName, pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + expected + ); + } + ); + + it.each([ + ['authenticator', 'authenticator/html-form/index', '/symbols/html-form.svg'], + ['authentication-action', 'authentication-action/email-verifier/verify', '/symbols/email-verifier.svg'], + ['consentor', 'consentor/scope-consent/consent', '/symbols/scope-consent.svg'], + ])( + 'falls back to the plugin-type entry for the %s category when no `views` entry matches', + (_, viewName, expected) => { + renderPageSymbol(viewName, pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + expected + ); + } + ); + + it('falls back to `default` when neither `views` nor `plugins` matches', () => { + renderPageSymbol('authenticator/unknown-plugin/index', pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + '/symbols/default.svg' + ); + }); + + it('falls back to `default` when the viewName is outside the three plugin categories', () => { + renderPageSymbol('views/select-authenticator/index', pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + '/symbols/default.svg' + ); + }); + + it('renders nothing when nothing resolves and no `default` is configured', () => { + renderPageSymbol('authenticator/unknown-plugin/index', { + plugins: { 'html-form': '/symbols/html-form.svg' }, + }); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when pageSymbols is absent', () => { + renderPageSymbol('authenticator/html-form/index', undefined); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when pageSymbols is empty', () => { + renderPageSymbol('authenticator/html-form/index', {}); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when viewName is undefined', () => { + renderPageSymbol(undefined, pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when viewName is an empty string', () => { + renderPageSymbol('', pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); +}); diff --git a/src/login-web-app/src/shared/ui/PageSymbol.tsx b/src/login-web-app/src/shared/ui/PageSymbol.tsx new file mode 100644 index 00000000..9b3cf4ee --- /dev/null +++ b/src/login-web-app/src/shared/ui/PageSymbol.tsx @@ -0,0 +1,35 @@ +/* + * 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 { useAppConfig } from '../feature/app-config/AppConfigHook'; +import { resolvePageSymbol } from '../util/resolve-page-symbol'; + +interface PageSymbolProps { + viewName: string | undefined; +} + +/** + * Renders the page symbol icon associated with the current step's HAAPI `viewName`. + * + * The mapping comes from `theme.pageSymbols` in the bootstrap configuration and is resolved by + * {@link resolvePageSymbol}. When `theme.pageSymbols` is absent, when `viewName` is absent, or when + * no entry resolves, this component renders nothing. + */ +export const PageSymbol = ({ viewName }: PageSymbolProps) => { + const { theme } = useAppConfig(); + const src = resolvePageSymbol(viewName, theme.pageSymbols); + + if (!src) { + return null; + } + + return ; +}; diff --git a/src/login-web-app/src/shared/util/css/styles.css b/src/login-web-app/src/shared/util/css/styles.css index feea940e..ed4cc626 100644 --- a/src/login-web-app/src/shared/util/css/styles.css +++ b/src/login-web-app/src/shared/util/css/styles.css @@ -248,6 +248,17 @@ svg { margin-block: 0 var(--space-2); } +.haapi-stepper-page-symbol { + display: block; + max-width: 64px; + max-height: 64px; + width: auto; + height: auto; + object-fit: contain; + margin-inline: auto; + margin-block: 0 var(--space-2); +} + .haapi-stepper-links { @extend .center, .py3, .flex, .flex-column; } diff --git a/src/login-web-app/src/shared/util/resolve-page-symbol.ts b/src/login-web-app/src/shared/util/resolve-page-symbol.ts new file mode 100644 index 00000000..a8c5e840 --- /dev/null +++ b/src/login-web-app/src/shared/util/resolve-page-symbol.ts @@ -0,0 +1,53 @@ +/* + * 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 type { PageSymbols } from '../../haapi-stepper/data-access/bootstrap-configuration'; + +/** + * Resolves the page symbol image path for a given HAAPI step `viewName` against the + * `theme.pageSymbols` configuration delivered by the server bootstrap. + * + * Resolution order: + * 1. Exact match in `pageSymbols.views`. + * 2. Plugin-type match (extracted from `viewName` via {@link PLUGIN_TYPE_FROM_VIEW_NAME}) in `pageSymbols.plugins`. + * 3. `pageSymbols.default`. + * 4. `undefined` when no rule resolves — callers should render nothing. + * + * Returns `undefined` for any falsy input (no `viewName`, no `pageSymbols`). + */ +export const resolvePageSymbol = ( + viewName: string | undefined, + pageSymbols: PageSymbols | undefined +): string | undefined => { + /** + * Extracts the plugin implementation type from a HAAPI `viewName` of the form + * `//`, where category is `authenticator`, `authentication-action` + * or `consentor`. + */ + const PLUGIN_TYPE_FROM_VIEW_NAME = /^(?:authenticator|authentication-action|consentor)\/([^/]+)\/.*/; + + if (!viewName || !pageSymbols) { + return undefined; + } + + const exactMatch = pageSymbols.views?.[viewName]; + if (exactMatch) { + return exactMatch; + } + + const pluginType = viewName.match(PLUGIN_TYPE_FROM_VIEW_NAME)?.[1]; + const pluginMatch = pluginType ? pageSymbols.plugins?.[pluginType] : undefined; + if (pluginMatch) { + return pluginMatch; + } + + return pageSymbols.default; +}; From a038fc1ecf51eaa35c4c65c2bce948febb6abca9 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 29 May 2026 14:27:00 +0200 Subject: [PATCH 02/15] IS-11346: prettier --- .../feature/steps/HaapiStepperStepUI.spec.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index 2b866ee1..ebafa39b 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -15,10 +15,7 @@ import userEvent from '@testing-library/user-event'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { HaapiStepperContext } from '../stepper/HaapiStepperContext'; import { AppConfigContext } from '../../../shared/feature/app-config/AppConfigContext'; -import type { - BootstrapConfiguration, - PageSymbols, -} from '../../data-access/bootstrap-configuration'; +import type { BootstrapConfiguration, PageSymbols } from '../../data-access/bootstrap-configuration'; import type { HaapiStepperAPI, HaapiStepperNextStep, @@ -2055,9 +2052,13 @@ describe('HaapiStepperStepUI', () => { metadata: { templateArea: 'lwa', viewName: 'authenticator/html-form/index' }, }); - renderWithContext(, { currentStep: step }, { - plugins: { 'html-form': '/symbols/html-form.svg' }, - }); + renderWithContext( + , + { currentStep: step }, + { + plugins: { 'html-form': '/symbols/html-form.svg' }, + } + ); const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; const messages = screen.getByTestId('messages'); @@ -2075,9 +2076,13 @@ describe('HaapiStepperStepUI', () => { const qrLink = createMockQrLink(); const step = createPollingStep({ links: [qrLink] }); - renderWithContext(, { currentStep: step }, { - plugins: { bankid: '/symbols/bankid.svg' }, - }); + renderWithContext( + , + { currentStep: step }, + { + plugins: { bankid: '/symbols/bankid.svg' }, + } + ); const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; const qrButton = screen.getByTestId('qr-code-button'); From b2dd1d0fcec4edea381c02e59ed325e288e29f56 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 29 May 2026 14:45:24 +0200 Subject: [PATCH 03/15] IS-11346: refcator to use exec --- src/login-web-app/src/shared/util/resolve-page-symbol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/login-web-app/src/shared/util/resolve-page-symbol.ts b/src/login-web-app/src/shared/util/resolve-page-symbol.ts index a8c5e840..b52b24af 100644 --- a/src/login-web-app/src/shared/util/resolve-page-symbol.ts +++ b/src/login-web-app/src/shared/util/resolve-page-symbol.ts @@ -43,7 +43,7 @@ export const resolvePageSymbol = ( return exactMatch; } - const pluginType = viewName.match(PLUGIN_TYPE_FROM_VIEW_NAME)?.[1]; + const pluginType = PLUGIN_TYPE_FROM_VIEW_NAME.exec(viewName)?.[1]; const pluginMatch = pluginType ? pageSymbols.plugins?.[pluginType] : undefined; if (pluginMatch) { return pluginMatch; From f4421824134af05b2d94c1003da1620b1099b250 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 29 May 2026 14:46:23 +0200 Subject: [PATCH 04/15] IS-11346: add AppConfigContext to previewer --- src/login-web-app/previewer/Previewer.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/login-web-app/previewer/Previewer.tsx b/src/login-web-app/previewer/Previewer.tsx index 88d30e55..60ca26d7 100644 --- a/src/login-web-app/previewer/Previewer.tsx +++ b/src/login-web-app/previewer/Previewer.tsx @@ -19,6 +19,16 @@ import { formatNextStepData } from '../src/haapi-stepper/feature/stepper/data-fo import { HaapiStepperContext } from '../src/haapi-stepper/feature/stepper/HaapiStepperContext'; import { type HaapiStepperAPI } from '../src/haapi-stepper/feature/stepper/haapi-stepper.types'; import { HaapiErrorStep } from '../src/haapi-stepper/data-access'; +import { AppConfigContext } from '../src/shared/feature/app-config/AppConfigContext'; +import type { BootstrapConfiguration } from '../src/haapi-stepper/data-access/bootstrap-configuration'; + +const mockAppConfig: BootstrapConfiguration = { + initialUrl: '', + haapi: { clientId: '', tokenEndpoint: '' }, + theme: { + logo: { path: '', isInsideWell: false }, + }, +}; enum Page { START = 'start', @@ -56,10 +66,12 @@ export function Previewer() { }; return ( - - setCurrentPage(page as Page)} currentPage={currentPage}> - {renderPreview()} - - + + + setCurrentPage(page as Page)} currentPage={currentPage}> + {renderPreview()} + + + ); } From 683f70952bf2fc3817ac006e8bd9df592256cfc0 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 28 May 2026 16:18:32 +0200 Subject: [PATCH 05/15] IS-11346: render page symbols for HAAPI steps Adds the LWA-side rendering of `theme.pageSymbols`. The `PageSymbol` component resolves the symbol URL for the current step's HAAPI `viewName` against the bootstrap configuration's `pageSymbols` map: 1. exact match in `views[viewName]` 2. plugin-type match in `plugins[]` (extracted from the `authenticator | authentication-action | consentor` viewName format) 3. fallback to `default` 4. otherwise renders nothing Wired into `HaapiStepperStepUI` so the symbol renders above the step's messages/actions/links, with a slot in `ViewNameBuiltInUIProps` so the BankID built-in places it above the lifted QR link. --- .../data-access/bootstrap-configuration.ts | 14 ++ .../feature/steps/HaapiStepperStepUI.spec.tsx | 68 +++++++++- .../feature/steps/HaapiStepperStepUI.tsx | 5 + .../viewnames/BankIdViewNameBuiltInUI.tsx | 11 +- .../feature/viewnames/typings.ts | 1 + .../src/shared/ui/PageSymbol.spec.tsx | 121 ++++++++++++++++++ .../src/shared/ui/PageSymbol.tsx | 35 +++++ .../src/shared/util/css/styles.css | 11 ++ .../src/shared/util/resolve-page-symbol.ts | 53 ++++++++ 9 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 src/login-web-app/src/shared/ui/PageSymbol.spec.tsx create mode 100644 src/login-web-app/src/shared/ui/PageSymbol.tsx create mode 100644 src/login-web-app/src/shared/util/resolve-page-symbol.ts diff --git a/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts b/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts index 34b64bf8..cf697d8c 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts @@ -19,9 +19,23 @@ export interface BootstrapConfiguration { path: string; isInsideWell: boolean; }; + /** + * Optional per-page icon configuration. Only present when symbols are enabled in the server theme. + * Resolved against the current step's `metadata.viewName` (see `resolvePageSymbol`). + */ + pageSymbols?: PageSymbols; }; } +export interface PageSymbols { + /** Map of full HAAPI viewName -> symbol path. Highest precedence. */ + views?: Record; + /** Map of plugin implementation type (e.g. `html-form`, `bankid`) -> symbol path. */ + plugins?: Record; + /** Fallback symbol path used when no per-view / per-plugin entry matches. */ + default?: string; +} + // @ts-expect-error window.__CONFIG__ is not declared on the Window type const _configuration = window.__CONFIG__ as BootstrapConfiguration | undefined; if (!_configuration) { diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index 19819cfd..375c5be0 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -14,6 +14,11 @@ import { useEffect } from 'react'; import userEvent from '@testing-library/user-event'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { HaapiStepperContext } from '../stepper/HaapiStepperContext'; +import { AppConfigContext } from '../../../shared/feature/app-config/AppConfigContext'; +import type { + BootstrapConfiguration, + PageSymbols, +} from '../../data-access/bootstrap-configuration'; import type { HaapiStepperAPI, HaapiStepperNextStep, @@ -67,13 +72,34 @@ import { } from '../../util/tests/mocks'; import { HaapiStepperFormFieldUI } from '../actions/form/fields/HaapiStepperFormFieldUI'; -const renderWithContext = (ui: React.ReactElement, contextValue: Partial = {}) => { +const buildAppConfig = (pageSymbols?: PageSymbols): BootstrapConfiguration => ({ + initialUrl: 'https://example/start', + haapi: {} as BootstrapConfiguration['haapi'], + theme: { + logo: { path: '/assets/logo.svg', isInsideWell: false }, + pageSymbols, + }, +}); + +/** Returns true when `a` appears before `b` in document order. */ +const rendersBefore = (a: Element, b: Element): boolean => + !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + +const renderWithContext = ( + ui: React.ReactElement, + contextValue: Partial = {}, + pageSymbols?: PageSymbols +) => { const value: HaapiStepperAPI = { ...defaultStepperAPI, ...contextValue, }; - return render({ui}); + return render( + + {ui} + + ); }; describe('HaapiStepperStepUI', () => { @@ -2028,4 +2054,42 @@ describe('HaapiStepperStepUI', () => { }); }); }); + + describe('Page symbol', () => { + it('renders the resolved symbol above the messages/actions/links for the current step', () => { + const step = createMockStep(HAAPI_STEPS.AUTHENTICATION, { + metadata: { templateArea: 'lwa', viewName: 'authenticator/html-form/index' }, + }); + + renderWithContext(, { currentStep: step }, { + plugins: { 'html-form': '/symbols/html-form.svg' }, + }); + + const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; + const messages = screen.getByTestId('messages'); + + expect(pageSymbol).toHaveAttribute('src', '/symbols/html-form.svg'); + expect(rendersBefore(pageSymbol, messages)).toBe(true); + }); + + it('renders nothing when theme.pageSymbols is absent', () => { + renderWithContext(); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders the page symbol above the BankID QR link in the BankID built-in UI', () => { + const qrLink = createMockQrLink(); + const step = createPollingStep({ links: [qrLink] }); + + renderWithContext(, { currentStep: step }, { + plugins: { bankid: '/symbols/bankid.svg' }, + }); + + const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; + const qrButton = screen.getByTestId('qr-code-button'); + + expect(pageSymbol).toHaveAttribute('src', '/symbols/bankid.svg'); + expect(rendersBefore(pageSymbol, qrButton)).toBe(true); + }); + }); }); diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx index d8bde513..6a471b2e 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx @@ -14,6 +14,7 @@ import { formatNextStepData } from '../stepper/data-formatters/format-next-step- import { getViewNameBuiltInUI } from '../viewnames'; import type { HaapiStepperAPIWithRequiredCurrentStep } from '../stepper/haapi-stepper.types'; import { useHaapiStepper } from '../stepper/HaapiStepperHook'; +import { PageSymbol } from '../../../shared/ui/PageSymbol'; import { getActionsElement, getErrorElement, @@ -259,6 +260,8 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => { const linksToDisplay = getLinksToDisplay(error, currentStep); const messagesToDisplay = error?.input ? error.input.dataHelpers.messages : currentStep.dataHelpers.messages; + const pageSymbolElement = ; + const stepElements = { loadingElement, errorElement: getErrorElement(haapiStepperUiAPI, errorRenderInterceptor), @@ -272,6 +275,7 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => { clientOperationActionRenderInterceptor ), linksElement: getLinksElement(haapiStepperUiAPI, linksToDisplay, linkRenderInterceptor), + pageSymbolElement, }; const ViewNameBuiltInUI = getViewNameBuiltInUI(haapiStepperUiAPI); @@ -282,6 +286,7 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => { return ( <> + {stepElements.pageSymbolElement} {stepElements.loadingElement} {stepElements.errorElement} {stepElements.messagesElement} diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx index 26cb897d..c458c72b 100644 --- a/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx @@ -19,13 +19,22 @@ import type { ViewNameBuiltInUIProps } from './typings'; * - Lifts the QR code link above the actions so it's the primary element on the screen. */ export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => { - const { currentStep, linkRenderInterceptor, loadingElement, errorElement, messagesElement, actionsElement } = props; + const { + currentStep, + linkRenderInterceptor, + loadingElement, + errorElement, + messagesElement, + actionsElement, + pageSymbolElement, + } = props; const { links } = currentStep.dataHelpers; const qrLink = links.find(isQrCodeLink); const nonQrLinks = links.filter(link => !isQrCodeLink(link)); return ( <> + {pageSymbolElement} {loadingElement} {errorElement} {messagesElement} diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts b/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts index 9625a148..3596e333 100644 --- a/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts @@ -26,4 +26,5 @@ export type ViewNameBuiltInUIProps = HaapiStepperAPIWithRequiredCurrentStep & messagesElement: ReactElement; actionsElement: ReactElement | null; linksElement: ReactElement; + pageSymbolElement: ReactElement; }; diff --git a/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx b/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx new file mode 100644 index 00000000..7b63e1aa --- /dev/null +++ b/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx @@ -0,0 +1,121 @@ +/* + * 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 { render } from '@testing-library/react'; +import { PageSymbol } from './PageSymbol'; +import { AppConfigContext } from '../feature/app-config/AppConfigContext'; +import type { BootstrapConfiguration, PageSymbols } from '../../haapi-stepper/data-access/bootstrap-configuration'; + +const buildConfig = (pageSymbols?: PageSymbols): BootstrapConfiguration => ({ + initialUrl: 'https://example/start', + haapi: {} as BootstrapConfiguration['haapi'], + theme: { + logo: { path: '/assets/logo.svg', isInsideWell: false }, + pageSymbols, + }, +}); + +const renderPageSymbol = (viewName: string | undefined, pageSymbols?: PageSymbols) => + render( + + + + ); + +describe('PageSymbol', () => { + const pageSymbols: PageSymbols = { + views: { + 'authenticator/html-form/create-account/post': '/symbols/create-account.svg', + 'authentication-action/email-verifier/confirm': '/symbols/email-verifier-confirm.svg', + 'consentor/scope-consent/review': '/symbols/scope-consent-review.svg', + }, + plugins: { + 'html-form': '/symbols/html-form.svg', + 'email-verifier': '/symbols/email-verifier.svg', + 'scope-consent': '/symbols/scope-consent.svg', + }, + default: '/symbols/default.svg', + }; + + it.each([ + ['authenticator', 'authenticator/html-form/create-account/post', '/symbols/create-account.svg'], + ['authentication-action', 'authentication-action/email-verifier/confirm', '/symbols/email-verifier-confirm.svg'], + ['consentor', 'consentor/scope-consent/review', '/symbols/scope-consent-review.svg'], + ])( + 'renders the exact `views` entry for the %s category even when a plugin or default would also match', + (_, viewName, expected) => { + renderPageSymbol(viewName, pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + expected + ); + } + ); + + it.each([ + ['authenticator', 'authenticator/html-form/index', '/symbols/html-form.svg'], + ['authentication-action', 'authentication-action/email-verifier/verify', '/symbols/email-verifier.svg'], + ['consentor', 'consentor/scope-consent/consent', '/symbols/scope-consent.svg'], + ])( + 'falls back to the plugin-type entry for the %s category when no `views` entry matches', + (_, viewName, expected) => { + renderPageSymbol(viewName, pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + expected + ); + } + ); + + it('falls back to `default` when neither `views` nor `plugins` matches', () => { + renderPageSymbol('authenticator/unknown-plugin/index', pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + '/symbols/default.svg' + ); + }); + + it('falls back to `default` when the viewName is outside the three plugin categories', () => { + renderPageSymbol('views/select-authenticator/index', pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + '/symbols/default.svg' + ); + }); + + it('renders nothing when nothing resolves and no `default` is configured', () => { + renderPageSymbol('authenticator/unknown-plugin/index', { + plugins: { 'html-form': '/symbols/html-form.svg' }, + }); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when pageSymbols is absent', () => { + renderPageSymbol('authenticator/html-form/index', undefined); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when pageSymbols is empty', () => { + renderPageSymbol('authenticator/html-form/index', {}); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when viewName is undefined', () => { + renderPageSymbol(undefined, pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when viewName is an empty string', () => { + renderPageSymbol('', pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); +}); diff --git a/src/login-web-app/src/shared/ui/PageSymbol.tsx b/src/login-web-app/src/shared/ui/PageSymbol.tsx new file mode 100644 index 00000000..9b3cf4ee --- /dev/null +++ b/src/login-web-app/src/shared/ui/PageSymbol.tsx @@ -0,0 +1,35 @@ +/* + * 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 { useAppConfig } from '../feature/app-config/AppConfigHook'; +import { resolvePageSymbol } from '../util/resolve-page-symbol'; + +interface PageSymbolProps { + viewName: string | undefined; +} + +/** + * Renders the page symbol icon associated with the current step's HAAPI `viewName`. + * + * The mapping comes from `theme.pageSymbols` in the bootstrap configuration and is resolved by + * {@link resolvePageSymbol}. When `theme.pageSymbols` is absent, when `viewName` is absent, or when + * no entry resolves, this component renders nothing. + */ +export const PageSymbol = ({ viewName }: PageSymbolProps) => { + const { theme } = useAppConfig(); + const src = resolvePageSymbol(viewName, theme.pageSymbols); + + if (!src) { + return null; + } + + return ; +}; diff --git a/src/login-web-app/src/shared/util/css/styles.css b/src/login-web-app/src/shared/util/css/styles.css index feea940e..ed4cc626 100644 --- a/src/login-web-app/src/shared/util/css/styles.css +++ b/src/login-web-app/src/shared/util/css/styles.css @@ -248,6 +248,17 @@ svg { margin-block: 0 var(--space-2); } +.haapi-stepper-page-symbol { + display: block; + max-width: 64px; + max-height: 64px; + width: auto; + height: auto; + object-fit: contain; + margin-inline: auto; + margin-block: 0 var(--space-2); +} + .haapi-stepper-links { @extend .center, .py3, .flex, .flex-column; } diff --git a/src/login-web-app/src/shared/util/resolve-page-symbol.ts b/src/login-web-app/src/shared/util/resolve-page-symbol.ts new file mode 100644 index 00000000..a8c5e840 --- /dev/null +++ b/src/login-web-app/src/shared/util/resolve-page-symbol.ts @@ -0,0 +1,53 @@ +/* + * 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 type { PageSymbols } from '../../haapi-stepper/data-access/bootstrap-configuration'; + +/** + * Resolves the page symbol image path for a given HAAPI step `viewName` against the + * `theme.pageSymbols` configuration delivered by the server bootstrap. + * + * Resolution order: + * 1. Exact match in `pageSymbols.views`. + * 2. Plugin-type match (extracted from `viewName` via {@link PLUGIN_TYPE_FROM_VIEW_NAME}) in `pageSymbols.plugins`. + * 3. `pageSymbols.default`. + * 4. `undefined` when no rule resolves — callers should render nothing. + * + * Returns `undefined` for any falsy input (no `viewName`, no `pageSymbols`). + */ +export const resolvePageSymbol = ( + viewName: string | undefined, + pageSymbols: PageSymbols | undefined +): string | undefined => { + /** + * Extracts the plugin implementation type from a HAAPI `viewName` of the form + * `//`, where category is `authenticator`, `authentication-action` + * or `consentor`. + */ + const PLUGIN_TYPE_FROM_VIEW_NAME = /^(?:authenticator|authentication-action|consentor)\/([^/]+)\/.*/; + + if (!viewName || !pageSymbols) { + return undefined; + } + + const exactMatch = pageSymbols.views?.[viewName]; + if (exactMatch) { + return exactMatch; + } + + const pluginType = viewName.match(PLUGIN_TYPE_FROM_VIEW_NAME)?.[1]; + const pluginMatch = pluginType ? pageSymbols.plugins?.[pluginType] : undefined; + if (pluginMatch) { + return pluginMatch; + } + + return pageSymbols.default; +}; From f0f560e5584938bf36320fa6738db31ee373efc5 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 29 May 2026 14:27:00 +0200 Subject: [PATCH 06/15] IS-11346: prettier --- .../feature/steps/HaapiStepperStepUI.spec.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index 375c5be0..c73e9558 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -15,10 +15,7 @@ import userEvent from '@testing-library/user-event'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { HaapiStepperContext } from '../stepper/HaapiStepperContext'; import { AppConfigContext } from '../../../shared/feature/app-config/AppConfigContext'; -import type { - BootstrapConfiguration, - PageSymbols, -} from '../../data-access/bootstrap-configuration'; +import type { BootstrapConfiguration, PageSymbols } from '../../data-access/bootstrap-configuration'; import type { HaapiStepperAPI, HaapiStepperNextStep, @@ -2061,9 +2058,13 @@ describe('HaapiStepperStepUI', () => { metadata: { templateArea: 'lwa', viewName: 'authenticator/html-form/index' }, }); - renderWithContext(, { currentStep: step }, { - plugins: { 'html-form': '/symbols/html-form.svg' }, - }); + renderWithContext( + , + { currentStep: step }, + { + plugins: { 'html-form': '/symbols/html-form.svg' }, + } + ); const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; const messages = screen.getByTestId('messages'); @@ -2081,9 +2082,13 @@ describe('HaapiStepperStepUI', () => { const qrLink = createMockQrLink(); const step = createPollingStep({ links: [qrLink] }); - renderWithContext(, { currentStep: step }, { - plugins: { bankid: '/symbols/bankid.svg' }, - }); + renderWithContext( + , + { currentStep: step }, + { + plugins: { bankid: '/symbols/bankid.svg' }, + } + ); const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; const qrButton = screen.getByTestId('qr-code-button'); From 7a7f23c5dd465957e488fc503d792f2c87a19e36 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 29 May 2026 14:45:24 +0200 Subject: [PATCH 07/15] IS-11346: refcator to use exec --- src/login-web-app/src/shared/util/resolve-page-symbol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/login-web-app/src/shared/util/resolve-page-symbol.ts b/src/login-web-app/src/shared/util/resolve-page-symbol.ts index a8c5e840..b52b24af 100644 --- a/src/login-web-app/src/shared/util/resolve-page-symbol.ts +++ b/src/login-web-app/src/shared/util/resolve-page-symbol.ts @@ -43,7 +43,7 @@ export const resolvePageSymbol = ( return exactMatch; } - const pluginType = viewName.match(PLUGIN_TYPE_FROM_VIEW_NAME)?.[1]; + const pluginType = PLUGIN_TYPE_FROM_VIEW_NAME.exec(viewName)?.[1]; const pluginMatch = pluginType ? pageSymbols.plugins?.[pluginType] : undefined; if (pluginMatch) { return pluginMatch; From eca8653a5b558e2acfe9a408b31d5fd56dc314d1 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 29 May 2026 14:46:23 +0200 Subject: [PATCH 08/15] IS-11346: add AppConfigContext to previewer --- src/login-web-app/previewer/Previewer.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/login-web-app/previewer/Previewer.tsx b/src/login-web-app/previewer/Previewer.tsx index 88d30e55..60ca26d7 100644 --- a/src/login-web-app/previewer/Previewer.tsx +++ b/src/login-web-app/previewer/Previewer.tsx @@ -19,6 +19,16 @@ import { formatNextStepData } from '../src/haapi-stepper/feature/stepper/data-fo import { HaapiStepperContext } from '../src/haapi-stepper/feature/stepper/HaapiStepperContext'; import { type HaapiStepperAPI } from '../src/haapi-stepper/feature/stepper/haapi-stepper.types'; import { HaapiErrorStep } from '../src/haapi-stepper/data-access'; +import { AppConfigContext } from '../src/shared/feature/app-config/AppConfigContext'; +import type { BootstrapConfiguration } from '../src/haapi-stepper/data-access/bootstrap-configuration'; + +const mockAppConfig: BootstrapConfiguration = { + initialUrl: '', + haapi: { clientId: '', tokenEndpoint: '' }, + theme: { + logo: { path: '', isInsideWell: false }, + }, +}; enum Page { START = 'start', @@ -56,10 +66,12 @@ export function Previewer() { }; return ( - - setCurrentPage(page as Page)} currentPage={currentPage}> - {renderPreview()} - - + + + setCurrentPage(page as Page)} currentPage={currentPage}> + {renderPreview()} + + + ); } From 87f95aa278f920692d8d022ac32b519c37b0463a Mon Sep 17 00:00:00 2001 From: Urban Sanden Date: Fri, 29 May 2026 16:34:40 +0200 Subject: [PATCH 09/15] IS-5161 IS-11346 wrap page symbol in figure and use CSS variables for sizing. --- .../src/shared/ui/PageSymbol.tsx | 6 ++++- .../src/shared/util/css/styles.css | 22 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/login-web-app/src/shared/ui/PageSymbol.tsx b/src/login-web-app/src/shared/ui/PageSymbol.tsx index 9b3cf4ee..269fa0ae 100644 --- a/src/login-web-app/src/shared/ui/PageSymbol.tsx +++ b/src/login-web-app/src/shared/ui/PageSymbol.tsx @@ -31,5 +31,9 @@ export const PageSymbol = ({ viewName }: PageSymbolProps) => { return null; } - return ; + return ( + + ); }; diff --git a/src/login-web-app/src/shared/util/css/styles.css b/src/login-web-app/src/shared/util/css/styles.css index ed4cc626..7cb364d4 100644 --- a/src/login-web-app/src/shared/util/css/styles.css +++ b/src/login-web-app/src/shared/util/css/styles.css @@ -249,14 +249,22 @@ svg { } .haapi-stepper-page-symbol { - display: block; - max-width: 64px; - max-height: 64px; - width: auto; - height: auto; + text-align: center; + width: var(--login-symbol-size); + height: var(--login-symbol-size); + display: var(--login-symbol-display); + margin: 0 auto; + justify-content: center; + align-items: center; + margin-top: var(--login-symbol-margin-top); + margin-bottom: var(--login-symbol-margin-bottom); + border-radius: var(--login-symbol-border-radius); +} + +.haapi-stepper-page-symbol-image { + width: var(--login-symbol-size); + height: var(--login-symbol-size); object-fit: contain; - margin-inline: auto; - margin-block: 0 var(--space-2); } .haapi-stepper-links { From 9b857f727238c4b17beda67269632fe44c4ac765 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 28 May 2026 16:18:32 +0200 Subject: [PATCH 10/15] IS-11346: render page symbols for HAAPI steps Adds the LWA-side rendering of `theme.pageSymbols`. The `PageSymbol` component resolves the symbol URL for the current step's HAAPI `viewName` against the bootstrap configuration's `pageSymbols` map: 1. exact match in `views[viewName]` 2. plugin-type match in `plugins[]` (extracted from the `authenticator | authentication-action | consentor` viewName format) 3. fallback to `default` 4. otherwise renders nothing Wired into `HaapiStepperStepUI` so the symbol renders above the step's messages/actions/links, with a slot in `ViewNameBuiltInUIProps` so the BankID built-in places it above the lifted QR link. --- .../data-access/bootstrap-configuration.ts | 14 ++ .../feature/steps/HaapiStepperStepUI.spec.tsx | 68 +++++++++- .../feature/steps/HaapiStepperStepUI.tsx | 5 + .../viewnames/BankIdViewNameBuiltInUI.tsx | 11 +- .../feature/viewnames/typings.ts | 1 + .../src/shared/ui/PageSymbol.spec.tsx | 121 ++++++++++++++++++ .../src/shared/ui/PageSymbol.tsx | 35 +++++ .../src/shared/util/css/styles.css | 11 ++ .../src/shared/util/resolve-page-symbol.ts | 53 ++++++++ 9 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 src/login-web-app/src/shared/ui/PageSymbol.spec.tsx create mode 100644 src/login-web-app/src/shared/ui/PageSymbol.tsx create mode 100644 src/login-web-app/src/shared/util/resolve-page-symbol.ts diff --git a/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts b/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts index 34b64bf8..cf697d8c 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/bootstrap-configuration.ts @@ -19,9 +19,23 @@ export interface BootstrapConfiguration { path: string; isInsideWell: boolean; }; + /** + * Optional per-page icon configuration. Only present when symbols are enabled in the server theme. + * Resolved against the current step's `metadata.viewName` (see `resolvePageSymbol`). + */ + pageSymbols?: PageSymbols; }; } +export interface PageSymbols { + /** Map of full HAAPI viewName -> symbol path. Highest precedence. */ + views?: Record; + /** Map of plugin implementation type (e.g. `html-form`, `bankid`) -> symbol path. */ + plugins?: Record; + /** Fallback symbol path used when no per-view / per-plugin entry matches. */ + default?: string; +} + // @ts-expect-error window.__CONFIG__ is not declared on the Window type const _configuration = window.__CONFIG__ as BootstrapConfiguration | undefined; if (!_configuration) { diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index 19819cfd..375c5be0 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -14,6 +14,11 @@ import { useEffect } from 'react'; import userEvent from '@testing-library/user-event'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { HaapiStepperContext } from '../stepper/HaapiStepperContext'; +import { AppConfigContext } from '../../../shared/feature/app-config/AppConfigContext'; +import type { + BootstrapConfiguration, + PageSymbols, +} from '../../data-access/bootstrap-configuration'; import type { HaapiStepperAPI, HaapiStepperNextStep, @@ -67,13 +72,34 @@ import { } from '../../util/tests/mocks'; import { HaapiStepperFormFieldUI } from '../actions/form/fields/HaapiStepperFormFieldUI'; -const renderWithContext = (ui: React.ReactElement, contextValue: Partial = {}) => { +const buildAppConfig = (pageSymbols?: PageSymbols): BootstrapConfiguration => ({ + initialUrl: 'https://example/start', + haapi: {} as BootstrapConfiguration['haapi'], + theme: { + logo: { path: '/assets/logo.svg', isInsideWell: false }, + pageSymbols, + }, +}); + +/** Returns true when `a` appears before `b` in document order. */ +const rendersBefore = (a: Element, b: Element): boolean => + !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + +const renderWithContext = ( + ui: React.ReactElement, + contextValue: Partial = {}, + pageSymbols?: PageSymbols +) => { const value: HaapiStepperAPI = { ...defaultStepperAPI, ...contextValue, }; - return render({ui}); + return render( + + {ui} + + ); }; describe('HaapiStepperStepUI', () => { @@ -2028,4 +2054,42 @@ describe('HaapiStepperStepUI', () => { }); }); }); + + describe('Page symbol', () => { + it('renders the resolved symbol above the messages/actions/links for the current step', () => { + const step = createMockStep(HAAPI_STEPS.AUTHENTICATION, { + metadata: { templateArea: 'lwa', viewName: 'authenticator/html-form/index' }, + }); + + renderWithContext(, { currentStep: step }, { + plugins: { 'html-form': '/symbols/html-form.svg' }, + }); + + const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; + const messages = screen.getByTestId('messages'); + + expect(pageSymbol).toHaveAttribute('src', '/symbols/html-form.svg'); + expect(rendersBefore(pageSymbol, messages)).toBe(true); + }); + + it('renders nothing when theme.pageSymbols is absent', () => { + renderWithContext(); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders the page symbol above the BankID QR link in the BankID built-in UI', () => { + const qrLink = createMockQrLink(); + const step = createPollingStep({ links: [qrLink] }); + + renderWithContext(, { currentStep: step }, { + plugins: { bankid: '/symbols/bankid.svg' }, + }); + + const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; + const qrButton = screen.getByTestId('qr-code-button'); + + expect(pageSymbol).toHaveAttribute('src', '/symbols/bankid.svg'); + expect(rendersBefore(pageSymbol, qrButton)).toBe(true); + }); + }); }); diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx index d8bde513..6a471b2e 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx @@ -14,6 +14,7 @@ import { formatNextStepData } from '../stepper/data-formatters/format-next-step- import { getViewNameBuiltInUI } from '../viewnames'; import type { HaapiStepperAPIWithRequiredCurrentStep } from '../stepper/haapi-stepper.types'; import { useHaapiStepper } from '../stepper/HaapiStepperHook'; +import { PageSymbol } from '../../../shared/ui/PageSymbol'; import { getActionsElement, getErrorElement, @@ -259,6 +260,8 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => { const linksToDisplay = getLinksToDisplay(error, currentStep); const messagesToDisplay = error?.input ? error.input.dataHelpers.messages : currentStep.dataHelpers.messages; + const pageSymbolElement = ; + const stepElements = { loadingElement, errorElement: getErrorElement(haapiStepperUiAPI, errorRenderInterceptor), @@ -272,6 +275,7 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => { clientOperationActionRenderInterceptor ), linksElement: getLinksElement(haapiStepperUiAPI, linksToDisplay, linkRenderInterceptor), + pageSymbolElement, }; const ViewNameBuiltInUI = getViewNameBuiltInUI(haapiStepperUiAPI); @@ -282,6 +286,7 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => { return ( <> + {stepElements.pageSymbolElement} {stepElements.loadingElement} {stepElements.errorElement} {stepElements.messagesElement} diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx index 26cb897d..c458c72b 100644 --- a/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx @@ -19,13 +19,22 @@ import type { ViewNameBuiltInUIProps } from './typings'; * - Lifts the QR code link above the actions so it's the primary element on the screen. */ export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => { - const { currentStep, linkRenderInterceptor, loadingElement, errorElement, messagesElement, actionsElement } = props; + const { + currentStep, + linkRenderInterceptor, + loadingElement, + errorElement, + messagesElement, + actionsElement, + pageSymbolElement, + } = props; const { links } = currentStep.dataHelpers; const qrLink = links.find(isQrCodeLink); const nonQrLinks = links.filter(link => !isQrCodeLink(link)); return ( <> + {pageSymbolElement} {loadingElement} {errorElement} {messagesElement} diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts b/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts index 9625a148..3596e333 100644 --- a/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts @@ -26,4 +26,5 @@ export type ViewNameBuiltInUIProps = HaapiStepperAPIWithRequiredCurrentStep & messagesElement: ReactElement; actionsElement: ReactElement | null; linksElement: ReactElement; + pageSymbolElement: ReactElement; }; diff --git a/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx b/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx new file mode 100644 index 00000000..7b63e1aa --- /dev/null +++ b/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx @@ -0,0 +1,121 @@ +/* + * 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 { render } from '@testing-library/react'; +import { PageSymbol } from './PageSymbol'; +import { AppConfigContext } from '../feature/app-config/AppConfigContext'; +import type { BootstrapConfiguration, PageSymbols } from '../../haapi-stepper/data-access/bootstrap-configuration'; + +const buildConfig = (pageSymbols?: PageSymbols): BootstrapConfiguration => ({ + initialUrl: 'https://example/start', + haapi: {} as BootstrapConfiguration['haapi'], + theme: { + logo: { path: '/assets/logo.svg', isInsideWell: false }, + pageSymbols, + }, +}); + +const renderPageSymbol = (viewName: string | undefined, pageSymbols?: PageSymbols) => + render( + + + + ); + +describe('PageSymbol', () => { + const pageSymbols: PageSymbols = { + views: { + 'authenticator/html-form/create-account/post': '/symbols/create-account.svg', + 'authentication-action/email-verifier/confirm': '/symbols/email-verifier-confirm.svg', + 'consentor/scope-consent/review': '/symbols/scope-consent-review.svg', + }, + plugins: { + 'html-form': '/symbols/html-form.svg', + 'email-verifier': '/symbols/email-verifier.svg', + 'scope-consent': '/symbols/scope-consent.svg', + }, + default: '/symbols/default.svg', + }; + + it.each([ + ['authenticator', 'authenticator/html-form/create-account/post', '/symbols/create-account.svg'], + ['authentication-action', 'authentication-action/email-verifier/confirm', '/symbols/email-verifier-confirm.svg'], + ['consentor', 'consentor/scope-consent/review', '/symbols/scope-consent-review.svg'], + ])( + 'renders the exact `views` entry for the %s category even when a plugin or default would also match', + (_, viewName, expected) => { + renderPageSymbol(viewName, pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + expected + ); + } + ); + + it.each([ + ['authenticator', 'authenticator/html-form/index', '/symbols/html-form.svg'], + ['authentication-action', 'authentication-action/email-verifier/verify', '/symbols/email-verifier.svg'], + ['consentor', 'consentor/scope-consent/consent', '/symbols/scope-consent.svg'], + ])( + 'falls back to the plugin-type entry for the %s category when no `views` entry matches', + (_, viewName, expected) => { + renderPageSymbol(viewName, pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + expected + ); + } + ); + + it('falls back to `default` when neither `views` nor `plugins` matches', () => { + renderPageSymbol('authenticator/unknown-plugin/index', pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + '/symbols/default.svg' + ); + }); + + it('falls back to `default` when the viewName is outside the three plugin categories', () => { + renderPageSymbol('views/select-authenticator/index', pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + 'src', + '/symbols/default.svg' + ); + }); + + it('renders nothing when nothing resolves and no `default` is configured', () => { + renderPageSymbol('authenticator/unknown-plugin/index', { + plugins: { 'html-form': '/symbols/html-form.svg' }, + }); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when pageSymbols is absent', () => { + renderPageSymbol('authenticator/html-form/index', undefined); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when pageSymbols is empty', () => { + renderPageSymbol('authenticator/html-form/index', {}); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when viewName is undefined', () => { + renderPageSymbol(undefined, pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); + + it('renders nothing when viewName is an empty string', () => { + renderPageSymbol('', pageSymbols); + expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + }); +}); diff --git a/src/login-web-app/src/shared/ui/PageSymbol.tsx b/src/login-web-app/src/shared/ui/PageSymbol.tsx new file mode 100644 index 00000000..9b3cf4ee --- /dev/null +++ b/src/login-web-app/src/shared/ui/PageSymbol.tsx @@ -0,0 +1,35 @@ +/* + * 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 { useAppConfig } from '../feature/app-config/AppConfigHook'; +import { resolvePageSymbol } from '../util/resolve-page-symbol'; + +interface PageSymbolProps { + viewName: string | undefined; +} + +/** + * Renders the page symbol icon associated with the current step's HAAPI `viewName`. + * + * The mapping comes from `theme.pageSymbols` in the bootstrap configuration and is resolved by + * {@link resolvePageSymbol}. When `theme.pageSymbols` is absent, when `viewName` is absent, or when + * no entry resolves, this component renders nothing. + */ +export const PageSymbol = ({ viewName }: PageSymbolProps) => { + const { theme } = useAppConfig(); + const src = resolvePageSymbol(viewName, theme.pageSymbols); + + if (!src) { + return null; + } + + return ; +}; diff --git a/src/login-web-app/src/shared/util/css/styles.css b/src/login-web-app/src/shared/util/css/styles.css index feea940e..ed4cc626 100644 --- a/src/login-web-app/src/shared/util/css/styles.css +++ b/src/login-web-app/src/shared/util/css/styles.css @@ -248,6 +248,17 @@ svg { margin-block: 0 var(--space-2); } +.haapi-stepper-page-symbol { + display: block; + max-width: 64px; + max-height: 64px; + width: auto; + height: auto; + object-fit: contain; + margin-inline: auto; + margin-block: 0 var(--space-2); +} + .haapi-stepper-links { @extend .center, .py3, .flex, .flex-column; } diff --git a/src/login-web-app/src/shared/util/resolve-page-symbol.ts b/src/login-web-app/src/shared/util/resolve-page-symbol.ts new file mode 100644 index 00000000..a8c5e840 --- /dev/null +++ b/src/login-web-app/src/shared/util/resolve-page-symbol.ts @@ -0,0 +1,53 @@ +/* + * 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 type { PageSymbols } from '../../haapi-stepper/data-access/bootstrap-configuration'; + +/** + * Resolves the page symbol image path for a given HAAPI step `viewName` against the + * `theme.pageSymbols` configuration delivered by the server bootstrap. + * + * Resolution order: + * 1. Exact match in `pageSymbols.views`. + * 2. Plugin-type match (extracted from `viewName` via {@link PLUGIN_TYPE_FROM_VIEW_NAME}) in `pageSymbols.plugins`. + * 3. `pageSymbols.default`. + * 4. `undefined` when no rule resolves — callers should render nothing. + * + * Returns `undefined` for any falsy input (no `viewName`, no `pageSymbols`). + */ +export const resolvePageSymbol = ( + viewName: string | undefined, + pageSymbols: PageSymbols | undefined +): string | undefined => { + /** + * Extracts the plugin implementation type from a HAAPI `viewName` of the form + * `//`, where category is `authenticator`, `authentication-action` + * or `consentor`. + */ + const PLUGIN_TYPE_FROM_VIEW_NAME = /^(?:authenticator|authentication-action|consentor)\/([^/]+)\/.*/; + + if (!viewName || !pageSymbols) { + return undefined; + } + + const exactMatch = pageSymbols.views?.[viewName]; + if (exactMatch) { + return exactMatch; + } + + const pluginType = viewName.match(PLUGIN_TYPE_FROM_VIEW_NAME)?.[1]; + const pluginMatch = pluginType ? pageSymbols.plugins?.[pluginType] : undefined; + if (pluginMatch) { + return pluginMatch; + } + + return pageSymbols.default; +}; From 08a16cef334e5768419ca201a0c099b156a4f7d4 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 29 May 2026 14:27:00 +0200 Subject: [PATCH 11/15] IS-11346: prettier --- .../feature/steps/HaapiStepperStepUI.spec.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index 375c5be0..c73e9558 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -15,10 +15,7 @@ import userEvent from '@testing-library/user-event'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { HaapiStepperContext } from '../stepper/HaapiStepperContext'; import { AppConfigContext } from '../../../shared/feature/app-config/AppConfigContext'; -import type { - BootstrapConfiguration, - PageSymbols, -} from '../../data-access/bootstrap-configuration'; +import type { BootstrapConfiguration, PageSymbols } from '../../data-access/bootstrap-configuration'; import type { HaapiStepperAPI, HaapiStepperNextStep, @@ -2061,9 +2058,13 @@ describe('HaapiStepperStepUI', () => { metadata: { templateArea: 'lwa', viewName: 'authenticator/html-form/index' }, }); - renderWithContext(, { currentStep: step }, { - plugins: { 'html-form': '/symbols/html-form.svg' }, - }); + renderWithContext( + , + { currentStep: step }, + { + plugins: { 'html-form': '/symbols/html-form.svg' }, + } + ); const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; const messages = screen.getByTestId('messages'); @@ -2081,9 +2082,13 @@ describe('HaapiStepperStepUI', () => { const qrLink = createMockQrLink(); const step = createPollingStep({ links: [qrLink] }); - renderWithContext(, { currentStep: step }, { - plugins: { bankid: '/symbols/bankid.svg' }, - }); + renderWithContext( + , + { currentStep: step }, + { + plugins: { bankid: '/symbols/bankid.svg' }, + } + ); const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; const qrButton = screen.getByTestId('qr-code-button'); From 0fbeaaeb34d88f052d27dce471462da6a6701abb Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 29 May 2026 14:45:24 +0200 Subject: [PATCH 12/15] IS-11346: refcator to use exec --- src/login-web-app/src/shared/util/resolve-page-symbol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/login-web-app/src/shared/util/resolve-page-symbol.ts b/src/login-web-app/src/shared/util/resolve-page-symbol.ts index a8c5e840..b52b24af 100644 --- a/src/login-web-app/src/shared/util/resolve-page-symbol.ts +++ b/src/login-web-app/src/shared/util/resolve-page-symbol.ts @@ -43,7 +43,7 @@ export const resolvePageSymbol = ( return exactMatch; } - const pluginType = viewName.match(PLUGIN_TYPE_FROM_VIEW_NAME)?.[1]; + const pluginType = PLUGIN_TYPE_FROM_VIEW_NAME.exec(viewName)?.[1]; const pluginMatch = pluginType ? pageSymbols.plugins?.[pluginType] : undefined; if (pluginMatch) { return pluginMatch; From 1d2df7dff5da0bfbe402f3c03ceb1243a2acec42 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 29 May 2026 14:46:23 +0200 Subject: [PATCH 13/15] IS-11346: add AppConfigContext to previewer --- src/login-web-app/previewer/Previewer.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/login-web-app/previewer/Previewer.tsx b/src/login-web-app/previewer/Previewer.tsx index 88d30e55..60ca26d7 100644 --- a/src/login-web-app/previewer/Previewer.tsx +++ b/src/login-web-app/previewer/Previewer.tsx @@ -19,6 +19,16 @@ import { formatNextStepData } from '../src/haapi-stepper/feature/stepper/data-fo import { HaapiStepperContext } from '../src/haapi-stepper/feature/stepper/HaapiStepperContext'; import { type HaapiStepperAPI } from '../src/haapi-stepper/feature/stepper/haapi-stepper.types'; import { HaapiErrorStep } from '../src/haapi-stepper/data-access'; +import { AppConfigContext } from '../src/shared/feature/app-config/AppConfigContext'; +import type { BootstrapConfiguration } from '../src/haapi-stepper/data-access/bootstrap-configuration'; + +const mockAppConfig: BootstrapConfiguration = { + initialUrl: '', + haapi: { clientId: '', tokenEndpoint: '' }, + theme: { + logo: { path: '', isInsideWell: false }, + }, +}; enum Page { START = 'start', @@ -56,10 +66,12 @@ export function Previewer() { }; return ( - - setCurrentPage(page as Page)} currentPage={currentPage}> - {renderPreview()} - - + + + setCurrentPage(page as Page)} currentPage={currentPage}> + {renderPreview()} + + + ); } From b16b5b64d95047e0331cb58518955e5fab0b9cfa Mon Sep 17 00:00:00 2001 From: Urban Sanden Date: Fri, 29 May 2026 16:34:40 +0200 Subject: [PATCH 14/15] IS-5161 IS-11346 wrap page symbol in figure and use CSS variables for sizing. --- .../src/shared/ui/PageSymbol.tsx | 6 ++++- .../src/shared/util/css/styles.css | 22 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/login-web-app/src/shared/ui/PageSymbol.tsx b/src/login-web-app/src/shared/ui/PageSymbol.tsx index 9b3cf4ee..269fa0ae 100644 --- a/src/login-web-app/src/shared/ui/PageSymbol.tsx +++ b/src/login-web-app/src/shared/ui/PageSymbol.tsx @@ -31,5 +31,9 @@ export const PageSymbol = ({ viewName }: PageSymbolProps) => { return null; } - return ; + return ( + + ); }; diff --git a/src/login-web-app/src/shared/util/css/styles.css b/src/login-web-app/src/shared/util/css/styles.css index ed4cc626..7cb364d4 100644 --- a/src/login-web-app/src/shared/util/css/styles.css +++ b/src/login-web-app/src/shared/util/css/styles.css @@ -249,14 +249,22 @@ svg { } .haapi-stepper-page-symbol { - display: block; - max-width: 64px; - max-height: 64px; - width: auto; - height: auto; + text-align: center; + width: var(--login-symbol-size); + height: var(--login-symbol-size); + display: var(--login-symbol-display); + margin: 0 auto; + justify-content: center; + align-items: center; + margin-top: var(--login-symbol-margin-top); + margin-bottom: var(--login-symbol-margin-bottom); + border-radius: var(--login-symbol-border-radius); +} + +.haapi-stepper-page-symbol-image { + width: var(--login-symbol-size); + height: var(--login-symbol-size); object-fit: contain; - margin-inline: auto; - margin-block: 0 var(--space-2); } .haapi-stepper-links { From fb5cc9327b095e553db4939e2a51db73584227bd Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Mon, 1 Jun 2026 12:04:15 +0200 Subject: [PATCH 15/15] IS-11346: fix tests --- .../feature/steps/HaapiStepperStepUI.spec.tsx | 6 +++--- .../src/shared/ui/PageSymbol.spec.tsx | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index c73e9558..5c566a25 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -2066,7 +2066,7 @@ describe('HaapiStepperStepUI', () => { } ); - const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; + const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol-image')!; const messages = screen.getByTestId('messages'); expect(pageSymbol).toHaveAttribute('src', '/symbols/html-form.svg'); @@ -2075,7 +2075,7 @@ describe('HaapiStepperStepUI', () => { it('renders nothing when theme.pageSymbols is absent', () => { renderWithContext(); - expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + expect(document.querySelector('img.haapi-stepper-page-symbol-image')).toBeNull(); }); it('renders the page symbol above the BankID QR link in the BankID built-in UI', () => { @@ -2090,7 +2090,7 @@ describe('HaapiStepperStepUI', () => { } ); - const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol')!; + const pageSymbol = document.querySelector('img.haapi-stepper-page-symbol-image')!; const qrButton = screen.getByTestId('qr-code-button'); expect(pageSymbol).toHaveAttribute('src', '/symbols/bankid.svg'); diff --git a/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx b/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx index 7b63e1aa..b489ca3a 100644 --- a/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx +++ b/src/login-web-app/src/shared/ui/PageSymbol.spec.tsx @@ -54,7 +54,7 @@ describe('PageSymbol', () => { 'renders the exact `views` entry for the %s category even when a plugin or default would also match', (_, viewName, expected) => { renderPageSymbol(viewName, pageSymbols); - expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + expect(document.querySelector('img.haapi-stepper-page-symbol-image')).toHaveAttribute( 'src', expected ); @@ -69,7 +69,7 @@ describe('PageSymbol', () => { 'falls back to the plugin-type entry for the %s category when no `views` entry matches', (_, viewName, expected) => { renderPageSymbol(viewName, pageSymbols); - expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + expect(document.querySelector('img.haapi-stepper-page-symbol-image')).toHaveAttribute( 'src', expected ); @@ -78,7 +78,7 @@ describe('PageSymbol', () => { it('falls back to `default` when neither `views` nor `plugins` matches', () => { renderPageSymbol('authenticator/unknown-plugin/index', pageSymbols); - expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + expect(document.querySelector('img.haapi-stepper-page-symbol-image')).toHaveAttribute( 'src', '/symbols/default.svg' ); @@ -86,7 +86,7 @@ describe('PageSymbol', () => { it('falls back to `default` when the viewName is outside the three plugin categories', () => { renderPageSymbol('views/select-authenticator/index', pageSymbols); - expect(document.querySelector('img.haapi-stepper-page-symbol')).toHaveAttribute( + expect(document.querySelector('img.haapi-stepper-page-symbol-image')).toHaveAttribute( 'src', '/symbols/default.svg' ); @@ -96,26 +96,26 @@ describe('PageSymbol', () => { renderPageSymbol('authenticator/unknown-plugin/index', { plugins: { 'html-form': '/symbols/html-form.svg' }, }); - expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + expect(document.querySelector('img.haapi-stepper-page-symbol-image')).toBeNull(); }); it('renders nothing when pageSymbols is absent', () => { renderPageSymbol('authenticator/html-form/index', undefined); - expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + expect(document.querySelector('img.haapi-stepper-page-symbol-image')).toBeNull(); }); it('renders nothing when pageSymbols is empty', () => { renderPageSymbol('authenticator/html-form/index', {}); - expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + expect(document.querySelector('img.haapi-stepper-page-symbol-image')).toBeNull(); }); it('renders nothing when viewName is undefined', () => { renderPageSymbol(undefined, pageSymbols); - expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + expect(document.querySelector('img.haapi-stepper-page-symbol-image')).toBeNull(); }); it('renders nothing when viewName is an empty string', () => { renderPageSymbol('', pageSymbols); - expect(document.querySelector('img.haapi-stepper-page-symbol')).toBeNull(); + expect(document.querySelector('img.haapi-stepper-page-symbol-image')).toBeNull(); }); });