Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/common/icons/src/components/general/IconGeneralEye.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as React from 'react';
import type { SVGProps } from 'react';
const SvgIconGeneralEye = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
width={props.width || `100%`}
height={props.height || `100%`}
stroke="currentColor"
fill="none"
{...props}
>
<path
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={14}
d="M44.48 124.51s29.7-59.41 81.68-59.41 81.68 59.41 81.68 59.41-29.7 59.41-81.68 59.41-81.68-59.41-81.68-59.41"
/>
<circle
cx={126.17}
cy={124.51}
r={22.28}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={14}
/>
</svg>
);
export default SvgIconGeneralEye;
1 change: 1 addition & 0 deletions src/common/icons/src/components/general/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { default as IconGeneralChevron } from './IconGeneralChevron';
export { default as IconGeneralClose } from './IconGeneralClose';
export { default as IconGeneralDownload } from './IconGeneralDownload';
export { default as IconGeneralEdit } from './IconGeneralEdit';
export { default as IconGeneralEye } from './IconGeneralEye';
export { default as IconGeneralEyeHide } from './IconGeneralEyeHide';
export { default as IconGeneralKebabMenu } from './IconGeneralKebabMenu';
export { default as IconGeneralLocation } from './IconGeneralLocation';
Expand Down
1 change: 1 addition & 0 deletions src/common/icons/src/icons.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ declare module '@icons' {
export { default as IconGeneralClose } from './@icons/components/general/IconGeneralClose';
export { default as IconGeneralDownload } from './@icons/components/general/IconGeneralDownload';
export { default as IconGeneralEdit } from './@icons/components/general/IconGeneralEdit';
export { default as IconGeneralEye } from './@icons/components/general/IconGeneralEye';
export { default as IconGeneralEyeHide } from './@icons/components/general/IconGeneralEyeHide';
export { default as IconGeneralKebabMenu } from './@icons/components/general/IconGeneralKebabMenu';
export { default as IconGeneralLocation } from './@icons/components/general/IconGeneralLocation';
Expand Down
22 changes: 15 additions & 7 deletions src/login-web-app/src/haapi-stepper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,23 @@ The HAAPI UI components reference the CSS classes listed below but do not ship a
|-------|---------|---------|
| `.haapi-stepper-selector` | `HaapiSelector` | Selector action container |
| `.haapi-stepper-messages` | `Messages` | Messages container |
| `.haapi-form-input` | `Form` | Text/password input fields |
| `.haapi-form-checkbox` | `Form` | Checkbox inputs |
| `.haapi-form-field-label` | `Form` | Form field labels |
| `.haapi-form-field-checkbox-label` | `Form` | Checkbox-specific labels |
| `.haapi-stepper-button` | `Form` | Primary submit buttons |
| `.haapi-stepper-button-outline` | `Form` | Outline/cancel buttons |
| `.haapi-stepper-form-field-text-input` | `HaapiStepperTextFormField` | Text input fields |
| `.haapi-stepper-form-field-text-label` | `HaapiStepperTextFormField` | Form field labels |
| `.haapi-stepper-form-field-checkbox-input` | `HaapiStepperCheckboxFormField` | Checkbox inputs |
| `.haapi-stepper-form-field-checkbox-label` | `HaapiStepperCheckboxFormField` | Checkbox-specific labels |
| `.haapi-stepper-form-field-select-input` | `HaapiStepperSelectFormField` | Select inputs |
| `.haapi-stepper-form-field-select-label` | `HaapiStepperSelectFormField` | Select-specific labels |
| `.haapi-stepper-form-field-password-wrapper` | `HaapiStepperPasswordFormField` | Password input container |
| `.haapi-stepper-form-field-password-label` | `HaapiStepperPasswordFormField` | Password label |
| `.haapi-stepper-form-field-password-input` | `HaapiStepperPasswordFormField` | Password input |
| `.haapi-stepper-form-field-password-visibility-toggle` | `HaapiStepperPasswordFormField` | Password visibility toggle button |
| `.haapi-stepper-button` | `HaapiStepperForm` | Primary submit buttons |
| `.haapi-stepper-button-outline` | `HaapiStepperForm` | Outline/cancel buttons |
| `.haapi-stepper-well` | `Well` | Styled content container |
| `.haapi-stepper-links` | `Links` | Links container |
| `.haapi-stepper-link` | `Link` | Link element |
| `.haapi-stepper-link-image` | `Link` | Linkselement |
| `.haapi-stepper-link-image` | `Link` | Link image (e.g. BankId QR code) |
| `.haapi-stepper-link-image-title` | `Link` | Link image's title |
| `.haapi-stepper-actions` | `Actions` | Actions container |
| `.haapi-stepper-heading` | `Messages` | Heading messages |
| `.haapi-stepper-userName` | `Messages` | User name display |
Expand All @@ -159,6 +166,7 @@ The HAAPI UI components reference the CSS classes listed below but do not ship a
| `.haapi-stepper-error-boundary-fallback` | `DefaultErrorFallback` | Error boundary fallback container |



## Error Handling
The `HaapiStepper` implements a comprehensive error-handling strategy with multiple layers to ensure robust error management and an optimal user experience.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';

import { HaapiStepperForm } from './HaapiStepperForm';
import { HAAPI_FORM_FIELDS, HTTP_METHODS, VisibleHaapiFormField } from '../../../data-access/types/haapi-form.types';
import { HAAPI_FORM_ACTION_KINDS } from '../../../data-access/types/haapi-action.types';
import { HAAPI_PROBLEM_STEPS } from '../../../data-access/types/haapi-step.types';
import { HaapiStepperFormField } from './fields/HaapiStepperFormField';
import { useHaapiStepper } from '../../stepper/HaapiStepperHook';
Expand Down Expand Up @@ -105,6 +106,74 @@ describe('HaapiStepperForm', () => {
expect(screen.getByText(validationMissingUsernameMessage)).toBeInTheDocument();
expect(screen.getByText(validationInvalidPasswordMessage)).toBeInTheDocument();
});

it('should allow showing and hiding password fields without losing their values', async () => {
const action = createLoginFormAction();
const onSubmit = vi.fn();

render(<HaapiStepperForm action={action} onSubmit={onSubmit} />);

// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const passwordInput = screen.getByTestId(
formFieldTestId(HAAPI_FORM_FIELDS.PASSWORD, passwordFieldName)
) as HTMLInputElement;
const showButton = screen.getByRole('button', { name: 'Show password' });

expect(passwordInput).toHaveAttribute('type', 'password');
await user.type(passwordInput, passwordValue);

await user.click(showButton);
const hideButton = screen.getByRole('button', { name: 'Hide password' });
expect(passwordInput).toHaveAttribute('type', 'text');
expect(hideButton).toHaveAttribute('aria-pressed', 'true');
expect(passwordInput.value).toBe(passwordValue);

await user.click(hideButton);
expect(passwordInput).toHaveAttribute('type', 'password');
expect(screen.getByRole('button', { name: 'Show password' })).toHaveAttribute('aria-pressed', 'false');
});

it('should expose HTML autocomplete hints for registration fields', () => {
const action = createMockFormAction({
kind: HAAPI_FORM_ACTION_KINDS.USER_REGISTER,
model: {
href: '/register',
method: HTTP_METHODS.POST,
fields: [
{ type: HAAPI_FORM_FIELDS.USERNAME, name: usernameFieldName, label: 'Username' },
{ type: HAAPI_FORM_FIELDS.PASSWORD, name: passwordFieldName, label: 'Password' },
],
},
});
const onSubmit = vi.fn();

render(<HaapiStepperForm action={action} onSubmit={onSubmit} />);

expect(screen.getByTestId(formFieldTestId(HAAPI_FORM_FIELDS.TEXT, usernameFieldName))).toHaveAttribute(
'autocomplete',
'username'
);
expect(screen.getByTestId(formFieldTestId(HAAPI_FORM_FIELDS.PASSWORD, passwordFieldName))).toHaveAttribute(
'autocomplete',
'new-password'
);
});

it('should expose HTML autocomplete hints for login fields', () => {
const action = createLoginFormAction();
const onSubmit = vi.fn();

render(<HaapiStepperForm action={action} onSubmit={onSubmit} />);

expect(screen.getByTestId(formFieldTestId(HAAPI_FORM_FIELDS.TEXT, usernameFieldName))).toHaveAttribute(
'autocomplete',
'username'
);
expect(screen.getByTestId(formFieldTestId(HAAPI_FORM_FIELDS.PASSWORD, passwordFieldName))).toHaveAttribute(
'autocomplete',
'current-password'
);
});
});

describe('Custom rendering', () => {
Expand Down Expand Up @@ -595,6 +664,7 @@ const createLoginFormAction = () =>
createMockFormAction({
id: loginFormId,
title: loginFormTitle,
kind: HAAPI_FORM_ACTION_KINDS.LOGIN,
model: {
href: loginFormActionHref,
method: HTTP_METHODS.POST,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ import type { HaapiCheckboxFormField } from '../../../../data-access/types/haapi
import { useHaapiStepperForm } from '../HaapiStepperFormContext';

export function HaapiStepperCheckboxFormField({ field }: { field: HaapiCheckboxFormField }): ReactElement {
const { formState } = useHaapiStepperForm();
const inputId = `${field.name}-input`;
const { formState, action } = useHaapiStepperForm();
const inputId = `${action.id}-${field.name}-input`;

return (
<label className="label block" htmlFor={inputId}>
{field.label ?? field.name}:
<label className="haapi-stepper-form-field-checkbox-label" htmlFor={inputId}>
{field.label ?? field.name}
<input
id={inputId}
data-testid={`haapi-form-field-checkbox-${field.name}`}
Expand All @@ -29,6 +29,7 @@ export function HaapiStepperCheckboxFormField({ field }: { field: HaapiCheckboxF
checked={formState.get(field)}
disabled={field.readonly ?? false}
onChange={e => formState.set(field, e.target.checked)}
className="haapi-stepper-form-field-checkbox-input"
/>
</label>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ import { HaapiFormField, HAAPI_FORM_FIELDS } from '../../../../data-access/types
import { HaapiStepperCheckboxFormField } from './HaapiStepperCheckboxFormField';
import { HaapiStepperSelectFormField } from './HaapiStepperSelectFormField';
import { HaapiStepperTextFormField } from './HaapiStepperTextFormField';
import { HaapiStepperPasswordFormField } from './HaapiStepperPasswordFormField';

export function HaapiStepperFormField({ field }: { field: HaapiFormField }): ReactElement {
switch (field.type) {
case HAAPI_FORM_FIELDS.SELECT:
return <HaapiStepperSelectFormField field={field} />;
case HAAPI_FORM_FIELDS.CHECKBOX:
return <HaapiStepperCheckboxFormField field={field} />;
case HAAPI_FORM_FIELDS.PASSWORD:
return <HaapiStepperPasswordFormField field={field} />;
default:
return <HaapiStepperTextFormField field={field} />;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright (C) 2025 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 { ReactElement } from 'react';
import { useState } from 'react';
import { IconGeneralEye, IconGeneralEyeHide } from '@curity/ui-kit-icons';

import { HAAPI_FORM_ACTION_KINDS, HAAPI_FORM_ACTION_KINDS_TYPE } from '../../../../data-access/types/haapi-action.types';
import { HAAPI_FORM_FIELDS, HaapiPasswordFormField } from '../../../../data-access/types/haapi-form.types';
import { useHaapiStepperForm } from '../HaapiStepperFormContext';

export function HaapiStepperPasswordFormField({ field }: { field: HaapiPasswordFormField }): ReactElement {
const { formState, action } = useHaapiStepperForm();
const [isVisible, setIsVisible] = useState(false);
const inputId = `${action.id}-${field.name}-input`;
const autoComplete = getPasswordAutoComplete(action.kind);
const ariaLabel = `${isVisible ? 'Hide' : 'Show'} password`;

return (
<label className="haapi-stepper-form-field-password-label" htmlFor={inputId}>
{field.label ?? field.name}
<div className="haapi-stepper-form-field-password-wrapper">
<input
id={inputId}
data-testid={`haapi-form-field-${HAAPI_FORM_FIELDS.PASSWORD}-${field.name}`}
type={isVisible ? 'text' : 'password'}
className="haapi-stepper-form-field-password-input"
name={field.name}
value={formState.get(field)}
placeholder={field.placeholder}
autoComplete={autoComplete}
onChange={event => formState.set(field, event.target.value)}
/>
<button
type="button"
className="haapi-stepper-form-field-password-visibility-toggle"
aria-label={ariaLabel}
aria-pressed={isVisible}
aria-controls={inputId}
onClick={() => setIsVisible(current => !current)}
>
{isVisible ? <IconGeneralEyeHide /> : <IconGeneralEye />}
</button>
</div>
</label>
);
}

const getPasswordAutoComplete = (
actionKind: HAAPI_FORM_ACTION_KINDS_TYPE
): 'current-password' | 'new-password' => {
const isRegistrationFlow = actionKind === HAAPI_FORM_ACTION_KINDS.USER_REGISTER;
Comment thread
aleixsuau marked this conversation as resolved.

if (isRegistrationFlow) {
return 'new-password';
}

return 'current-password';
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,19 @@ import type { HaapiSelectFormField } from '../../../../data-access/types/haapi-f
import { useHaapiStepperForm } from '../HaapiStepperFormContext';

export function HaapiStepperSelectFormField({ field }: { field: HaapiSelectFormField }): ReactElement {
const { formState } = useHaapiStepperForm();
const selectId = `${field.name}-input`;
const { formState, action } = useHaapiStepperForm();
const selectId = `${action.id}-${field.name}-input`;

return (
<label className="label block" htmlFor={selectId}>
{field.label ?? field.name}:
<label className="haapi-stepper-form-field-select-label" htmlFor={selectId}>
{field.label ?? field.name}
<select
id={selectId}
data-testid={`haapi-form-field-select-${field.name}`}
name={field.name}
value={formState.get(field)}
onChange={e => formState.set(field, e.target.value)}
className="haapi-stepper-form-field-select-input"
>
{field.options.map(option => (
<option key={option.value + '_' + option.label} value={option.value}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,26 @@ import {
HAAPI_FORM_FIELDS,
HaapiCheckboxFormField,
HaapiFormField,
HaapiPasswordFormField,
HaapiSelectFormField,
} from '../../../../data-access/types/haapi-form.types';
import { useHaapiStepperForm } from '../HaapiStepperFormContext';

export type TextLikeFormField = Exclude<HaapiFormField, HaapiCheckboxFormField | HaapiSelectFormField>;
export type TextLikeFormField = Exclude<HaapiFormField, HaapiCheckboxFormField | HaapiSelectFormField | HaapiPasswordFormField>;

export function HaapiStepperTextFormField({ field }: { field: TextLikeFormField }): ReactElement {
const { formState } = useHaapiStepperForm();
const inputType = field.type === HAAPI_FORM_FIELDS.PASSWORD ? field.type : 'text';
const autoComplete =
field.type === HAAPI_FORM_FIELDS.PASSWORD
? 'current-password'
: field.type === HAAPI_FORM_FIELDS.USERNAME
? 'username'
: undefined;
const inputId = `${field.name}-input`;
const { formState, action } = useHaapiStepperForm();
const autoComplete = getTextAutoComplete(field);
const inputId = `${action.id}-${field.name}-input`;

return (
<label className="haapi-form-field-label" htmlFor={inputId}>
<label className="haapi-stepper-form-field-text-label" htmlFor={inputId}>
{field.label ?? field.name}
<input
id={inputId}
data-testid={`haapi-form-field-${inputType}-${field.name}`}
type={inputType}
className="haapi-form-input "
data-testid={`haapi-form-field-${HAAPI_FORM_FIELDS.TEXT}-${field.name}`}
type="text"
className="haapi-stepper-form-field-text-input"
name={field.name}
value={formState.get(field)}
placeholder={field.placeholder}
Expand All @@ -49,3 +44,11 @@ export function HaapiStepperTextFormField({ field }: { field: TextLikeFormField
</label>
);
}

const getTextAutoComplete = (field: TextLikeFormField) => {
if (field.type === HAAPI_FORM_FIELDS.USERNAME) {
return 'username';
}

return undefined;
};
Loading
Loading