diff --git a/src/haapi-react-app/src/shared/util/css/styles.css b/src/haapi-react-app/src/shared/util/css/styles.css index a6a897a1..d5c13a7c 100644 --- a/src/haapi-react-app/src/shared/util/css/styles.css +++ b/src/haapi-react-app/src/shared/util/css/styles.css @@ -358,14 +358,18 @@ svg { width: min(100%, 100px); } -.haapi-stepper-polling-progress { - @extend .mt2; +.haapi-stepper-polling-progress-bar { + @extend .mt2, .mb2; + display: block; + width: 100%; + max-width: 250px; + margin-inline: auto; + border: none; border-radius: var(--form-field-border-radius); background-color: var(--color-grey-subtle); - outline: 1px solid var(--color-grey-light); - outline-offset: 2px; - border: none; + /* Set the track/fill at class specificity — the base `progress` element rules lose the cascade + here, which left the fill as the browser default (blue) instead of the brand colour. */ &::-webkit-progress-bar { background-color: var(--color-grey-subtle); border-radius: var(--form-field-border-radius); @@ -382,6 +386,11 @@ svg { } } +.haapi-stepper-polling-progress-duration { + @extend .center; + margin-block-start: var(--space-1); +} + .haapi-stepper-link-qr-code-button { background: none; border: none; diff --git a/src/haapi-react-sdk/haapi-stepper/README.md b/src/haapi-react-sdk/haapi-stepper/README.md index cbb4fe5e..ec6e2d80 100644 --- a/src/haapi-react-sdk/haapi-stepper/README.md +++ b/src/haapi-react-sdk/haapi-stepper/README.md @@ -238,7 +238,8 @@ The Curity utility composition shown above is just how *this* project chose to i | `.haapi-stepper-heading` | `HaapiStepperMessagesUI` | Heading messages | | `.haapi-stepper-userName` | `HaapiStepperMessagesUI` | User name display | | `.haapi-stepper-userCode` | `HaapiStepperMessagesUI` | User code display (e.g. recovery codes) | -| `.haapi-stepper-polling-progress` | `HaapiStepperClientOperationUI` | Remaining polling time indicator (e.g. recovery codes) | +| `.haapi-stepper-polling-progress-bar` | `HaapiStepperBankIdPollingProgressUI` | Remaining polling time indicator (the "authentication time" bar, e.g. BankID) | +| `.haapi-stepper-polling-progress-duration` | `HaapiStepperBankIdPollingProgressUI` | Numeric time-left readout shown below the bar (e.g. "24 seconds left") | | `.haapi-stepper-webauthn-registration-attachment` | `HaapiStepperWebAuthnRegistrationAttachmentCard` | WebAuthn registration attachment-selection option card (icon + title + description) | | `.haapi-stepper-webauthn-registration-attachment-icon` | `HaapiStepperWebAuthnRegistrationAttachmentCard` | Attachment card icon | | `.haapi-stepper-webauthn-registration-attachment-title` | `HaapiStepperWebAuthnRegistrationAttachmentCard` | Attachment card option label | diff --git a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx index 0d31fb4a..4a9a9ccf 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.spec.tsx @@ -15,7 +15,6 @@ import userEvent from '@testing-library/user-event'; import { HAAPI_STEPS } from '../../../data-access/types/haapi-step.types'; import { - createMockBankIdAction, createMockExternalBrowserFlowAction, createMockStep, createMockWebAuthnAnyDeviceBothOptionsAction, @@ -89,26 +88,6 @@ describe('HaapiStepperClientOperationUI', () => { }); }); - describe('BankID polling progress', () => { - it('renders a progress bar reflecting the session remaining time', () => { - const action = createMockBankIdAction({ maxWaitTime: 60, maxWaitRemainingTime: 30 }); - - render(); - - const progress = screen.getByRole('progressbar'); - expect(progress).toHaveAttribute('value', '30'); - expect(progress).toHaveAttribute('max', '60'); - }); - - it('hides the progress bar when showBankIdSessionTimeLeft is false', () => { - const action = createMockBankIdAction({ maxWaitTime: 60, maxWaitRemainingTime: 30 }); - - render(); - - expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); - }); - }); - describe('WebAuthn', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx index c1b935fa..ade371ec 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/actions/client-operation/HaapiStepperClientOperationUI.tsx @@ -16,7 +16,6 @@ import { useIsClientOperationAvailable } from './useIsClientOperationAvailable'; interface HaapiStepperClientOperationUIProps { action: HaapiStepperClientOperationAction; onAction: (action: HaapiStepperClientOperationAction | HaapiStepperFormAction) => void; - showBankIdSessionTimeLeft?: boolean; } /** @@ -44,11 +43,7 @@ interface HaapiStepperClientOperationUIProps { * * ``` */ -export function HaapiStepperClientOperationUI({ - action, - onAction, - showBankIdSessionTimeLeft = true, -}: HaapiStepperClientOperationUIProps) { +export function HaapiStepperClientOperationUI({ action, onAction }: HaapiStepperClientOperationUIProps) { const isAvailable = useIsClientOperationAvailable(action); if (action.webauthn?.registrationAttachment) { @@ -63,13 +58,6 @@ export function HaapiStepperClientOperationUI({ return (
- {showBankIdSessionTimeLeft && action.maxWaitRemainingTime !== undefined && ( - - )} diff --git a/src/haapi-react-sdk/haapi-stepper/feature/index.ts b/src/haapi-react-sdk/haapi-stepper/feature/index.ts index d657fd5f..724e8d49 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/index.ts +++ b/src/haapi-react-sdk/haapi-stepper/feature/index.ts @@ -20,6 +20,7 @@ export * from './stepper/step-handlers/polling-step'; export * from './stepper/data-formatters/problem-step'; export * from './steps/HaapiStepperStepUI'; +export * from './viewnames/HaapiStepperBankIdPollingProgressUI'; export * from './actions/form/HaapiStepperFormUI'; export * from './actions/form/HaapiStepperFormValidationErrorInputWrapper'; export * from './actions/form/HaapiStepperFormHook'; diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts index de5363d6..a909b3f1 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts @@ -22,7 +22,6 @@ import { } from '../../../data-access/types'; import { HaapiStepperAction, - HaapiStepperClientOperationAction, HaapiStepperDataHelpers, HaapiStepperDataHelpersActionsMap, HaapiStepperLink, @@ -110,18 +109,6 @@ function addActionDataHelpers( }; } - if (step.type === HAAPI_STEPS.POLLING && actionWithDataHelpers.subtype === HAAPI_ACTION_TYPES.CLIENT_OPERATION) { - const clientOperationPollingAction = { - ...actionWithDataHelpers, - ...(step.properties.maxWaitTime != null && { maxWaitTime: step.properties.maxWaitTime }), - ...(step.properties.maxWaitRemainingTime != null && { - maxWaitRemainingTime: step.properties.maxWaitRemainingTime, - }), - }; - - return clientOperationPollingAction as HaapiStepperClientOperationAction; - } - return { ...action, ...actionWithDataHelpers }; } diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts index 37814389..0ba82bcc 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts @@ -139,10 +139,6 @@ export type HaapiStepperSelectorAction = Omit & }; export type HaapiStepperClientOperationAction = HaapiClientOperationAction & HaapiStepperDataHelpersDetails & { - /** Polling session maximum time in seconds before the session expires. */ - maxWaitTime?: number; - /** Polling session remaining time in seconds before the session expires. */ - maxWaitRemainingTime?: number; /** * WebAuthn data resolved during step-data formatting. Present only on any-device * `webauthn-registration` actions. diff --git a/src/haapi-react-sdk/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index 34dc2f0e..f4226d9c 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -2017,7 +2017,83 @@ describe('HaapiStepperStepUI', () => { expect(screen.queryByTestId('messages')).toBeInTheDocument(); expect(screen.queryByTestId('links')).toBeInTheDocument(); }); + + it('should render the polling progress bar between the QR code and the actions', () => { + const step = createPollingStep({ + links: [createMockQrLink()], + actions: [createMockClientOperationAction({ title: 'Launch BankID App' })], + maxWaitTime: '60', + maxWaitRemainingTime: '30', + }); + + renderWithContext(, { currentStep: step }); + + const progress = screen.getByRole('progressbar', { hidden: true }); + expect(progress).toHaveAttribute('value', '30'); + expect(progress).toHaveAttribute('max', '60'); + + const qrCode = screen.getByTestId('qr-code-button'); + const actions = screen.getByTestId('client-operation-action'); + expect(follows(progress, qrCode)).toBe(true); + expect(follows(actions, progress)).toBe(true); + }); + + it('should render the progress bar sourced from the step even without a client-operation (QR-only mode)', () => { + const step = createPollingStep({ + links: [createMockQrLink()], + actions: [], + maxWaitTime: '60', + maxWaitRemainingTime: '30', + }); + + renderWithContext(, { currentStep: step }); + + expect(screen.getByRole('progressbar', { hidden: true })).toHaveAttribute('value', '30'); + expect(screen.queryByTestId('client-operation-action')).not.toBeInTheDocument(); + }); + + it('should not render the progress bar without a QR code (client-operation-only mode)', () => { + const step = createPollingStep({ + links: [], + actions: [createMockClientOperationAction({ title: 'Launch BankID App' })], + maxWaitTime: '60', + maxWaitRemainingTime: '30', + }); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByRole('progressbar', { hidden: true })).not.toBeInTheDocument(); + expect(screen.queryByTestId('qr-code-button')).not.toBeInTheDocument(); + }); + + it('should not render a progress bar when the step exposes no remaining wait time', () => { + const step = createPollingStep({ links: [createMockQrLink()] }); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByRole('progressbar', { hidden: true })).not.toBeInTheDocument(); + }); + + it.each([HAAPI_POLLING_STATUS.DONE, HAAPI_POLLING_STATUS.FAILED])( + 'should not render a progress bar for a %s polling step', + status => { + const step = createPollingStep({ + status, + links: [createMockQrLink()], + maxWaitTime: '60', + maxWaitRemainingTime: '30', + }); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByRole('progressbar', { hidden: true })).not.toBeInTheDocument(); + } + ); }); }); }); }); + +/** True when `node` appears after `reference` in document order. */ +const follows = (node: Element, reference: Element) => + Boolean(reference.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx index 997cba32..2afcc5f6 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx @@ -9,8 +9,10 @@ * For further information, please contact Curity AB. */ +import { HAAPI_STEPS } from '../../data-access/types/haapi-step.types'; import { isQrCodeLink } from '../../util/link-predicates'; import { getLinksElement } from '../steps/step-element-factories'; +import { HaapiStepperBankIdPollingProgressUI } from './HaapiStepperBankIdPollingProgressUI'; import { HaapiStepperBankIdQrCodeAccessibilityMessages } from './HaapiStepperBankIdQrCodeAccessibilityMessages'; import type { ViewNameBuiltInUIProps } from './typings'; @@ -18,6 +20,8 @@ import type { ViewNameBuiltInUIProps } from './typings'; * Built-in UI for the BankID viewName (`HaapiStepperViewNameBuiltInUI.BANKID`). * * - Lifts the QR code link above the actions so it's the primary element on the screen. + * - Renders the polling "authentication time" progress bar under the QR code, only when a QR code is + * present. * - Renders the QR-code accessibility messages (`metadata.viewData.messages`) as collapsible * sections below the QR code. */ @@ -32,9 +36,14 @@ export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => { {loadingElement} {errorElement} {messagesElement} - {qrCodeLink && getLinksElement(props, [qrCodeLink], linkRenderInterceptor)} {qrCodeLink && ( - + <> + {getLinksElement(props, [qrCodeLink], linkRenderInterceptor)} + {currentStep.type === HAAPI_STEPS.POLLING && ( + + )} + + )} {actionsElement} {nonQrCodeLinks.length > 0 && getLinksElement(props, nonQrCodeLinks, linkRenderInterceptor)} diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdPollingProgressUI.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdPollingProgressUI.spec.tsx new file mode 100644 index 00000000..085f45ee --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdPollingProgressUI.spec.tsx @@ -0,0 +1,159 @@ +/* + * 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; + +import { HaapiStepperBankIdPollingProgressUI } from './HaapiStepperBankIdPollingProgressUI'; +import { createPollingStep } from '../../util/tests/mocks'; +import { HAAPI_POLLING_STATUS } from '../../data-access/types/haapi-step.types'; + +const QR_MINUTES_LEFT_KEY = 'authenticator.bankid.launch.view.qr.minutes-left'; +const QR_SECONDS_LEFT_KEY = 'authenticator.bankid.launch.view.qr.seconds-left'; + +describe('HaapiStepperBankIdPollingProgressUI', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + const tick = async (ms: number) => { + await act(async () => { + vi.advanceTimersByTime(ms); + await Promise.resolve(); + }); + }; + + it('fills the bar with the elapsed portion of the session', () => { + render( + + ); + + const progress = screen.getByRole('progressbar', { hidden: true }); + expect(progress).toHaveAttribute('value', '40'); + expect(progress).toHaveAttribute('max', '60'); + }); + + it('fills the bar one second at a time, clamped at max', async () => { + render( + + ); + + expect(screen.getByRole('progressbar', { hidden: true })).toHaveAttribute('value', '57'); + + await tick(1000); + expect(screen.getByRole('progressbar', { hidden: true })).toHaveAttribute('value', '58'); + + await tick(5000); + expect(screen.getByRole('progressbar', { hidden: true })).toHaveAttribute('value', '60'); + }); + + it('progresses on the local countdown and ignores the server value on later polls', async () => { + const { rerender } = render( + + ); + + await tick(3000); + expect(screen.getByRole('progressbar', { hidden: true })).toHaveAttribute('value', '33'); + + rerender( + + ); + expect(screen.getByRole('progressbar', { hidden: true })).toHaveAttribute('value', '33'); + + rerender( + + ); + expect(screen.getByRole('progressbar', { hidden: true })).toHaveAttribute('value', '33'); + }); + + // "1:00" at exactly 60s matches the Velocity reference (curity-ui.js _qrTimer). + it.each([ + ['90', '1:30 minutes left'], + ['65', '1:05 minutes left'], + ['60', '1:00 minutes left'], + ['600', '10:00 minutes left'], + ['59', '59 seconds left'], + ['24', '24 seconds left'], + ['0', '0 seconds left'], + ])('formats %s seconds remaining as "%s"', (maxWaitRemainingTime, expected) => { + render( + + ); + + expect(screen.getByTestId('polling-progress-duration')).toHaveTextContent(expected); + }); + + it('renders only the bar when the step has no unit labels', () => { + render( + + ); + + expect(screen.getByRole('progressbar', { hidden: true })).toBeInTheDocument(); + expect(screen.queryByTestId('polling-progress-duration')).not.toBeInTheDocument(); + }); + + it('renders the readout but no bar when the step has no max wait time', () => { + render( + + ); + + expect(screen.queryByRole('progressbar', { hidden: true })).not.toBeInTheDocument(); + expect(screen.getByTestId('polling-progress-duration')).toHaveTextContent('20 seconds left'); + }); + + it('renders nothing when the polling step has no remaining wait time', () => { + const { container } = render( + + ); + + expect(screen.queryByRole('progressbar', { hidden: true })).not.toBeInTheDocument(); + expect(container).toBeEmptyDOMElement(); + }); + + it.each([HAAPI_POLLING_STATUS.DONE, HAAPI_POLLING_STATUS.FAILED])('renders nothing for a %s polling step', status => { + const { container } = render( + + ); + + expect(screen.queryByRole('progressbar', { hidden: true })).not.toBeInTheDocument(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdPollingProgressUI.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdPollingProgressUI.tsx new file mode 100644 index 00000000..cc5ca203 --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdPollingProgressUI.tsx @@ -0,0 +1,105 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { HAAPI_POLLING_STATUS } from '../../data-access/types/haapi-step.types'; +import type { HaapiStepperPollingStep } from '../stepper/haapi-stepper.types'; + +interface HaapiStepperBankIdPollingProgressUIProps { + /** The BankID polling step whose session time this bar reflects. */ + currentStep: HaapiStepperPollingStep; +} + +// BankID emits the countdown unit labels under this namespace in `metadata.viewData.messages` +// (see `WaitBankIdRepresentationFunction` in the server) — the same namespace as the QR accessibility copy. +const qrMessageKey = (suffix: string) => `authenticator.bankid.launch.view.qr.${suffix}`; + +const toSeconds = (value?: number | string): number | undefined => { + if (value === undefined) { + return undefined; + } + + const seconds = Number(value); + + return Number.isFinite(seconds) ? seconds : undefined; +}; + +/** + * @description + * # BANKID POLLING PROGRESS COMPONENT + * + * Renders the BankID "authentication time" progress bar for a *pending* polling step. Done and failed + * steps render nothing — they show their own outcome UI. + * + * The remaining time is seeded from the step's `maxWaitRemainingTime` once and counted down locally, + * one second at a time, clamped at 0. It is not re-synced on each poll: the server's remaining time + * can only arrive later/higher than the local count (poll cadence + latency), so re-syncing would only + * ever jerk the bar backwards. The bar shows elapsed time (so it fills toward timeout, matching the + * Velocity reference) while the readout counts remaining time down; the server decides when the session + * actually ends by returning a failed polling step. + * + * Accessibility: the bar is decorative (`aria-hidden`) — the adjacent numeric readout carries the + * remaining-time text for assistive tech, and only renders when the localized unit label is present in + * the step's `metadata.viewData.messages` (`minutes-left` for a minute or more left, `seconds-left` + * otherwise). + */ +export function HaapiStepperBankIdPollingProgressUI({ currentStep }: HaapiStepperBankIdPollingProgressUIProps) { + const { maxWaitTime, maxWaitRemainingTime, status } = currentStep.properties; + const viewDataMessages = currentStep.metadata?.viewData?.messages; + const minutesLeftLabel = viewDataMessages?.[qrMessageKey('minutes-left')]; + const secondsLeftLabel = viewDataMessages?.[qrMessageKey('seconds-left')]; + + const [remaining, setRemaining] = useState(() => toSeconds(maxWaitRemainingTime)); + + const shouldCountDown = status === HAAPI_POLLING_STATUS.PENDING && remaining !== undefined && remaining > 0; + + useEffect(() => { + if (!shouldCountDown) { + return; + } + + const intervalId = setInterval(() => { + setRemaining(previous => (previous === undefined ? previous : Math.max(0, previous - 1))); + }, 1000); + + return () => clearInterval(intervalId); + }, [shouldCountDown]); + + if (remaining === undefined || status !== HAAPI_POLLING_STATUS.PENDING) { + return null; + } + + const maxValue = toSeconds(maxWaitTime); + const totalSeconds = Math.floor(remaining); + const showMinutes = totalSeconds >= 60; + const readoutLabel = showMinutes ? minutesLeftLabel : secondsLeftLabel; + const readoutValue = showMinutes + ? `${String(Math.floor(totalSeconds / 60))}:${String(totalSeconds % 60).padStart(2, '0')}` + : String(totalSeconds); + + return ( + <> + {maxValue !== undefined && ( +