Merged in fix/LOY-382-password-validation-audit (pull request #3034)
Fix/LOY-382: Refactoring + accessibility of PasswordInput * fix(LOY-382): fix password accessibility issues * chore(LOY-382): refactor & add accessibility * refactor(LOY-382) Approved-by: Erik Tiekstra
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { passwordValidators } from "@scandic-hotels/common/utils/zod/passwordValidator"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import styles from "./passwordInput.module.css"
|
||||||
|
|
||||||
|
import type { PasswordValidatorKey } from "@/types/components/form/newPassword"
|
||||||
|
|
||||||
|
export function NewPasswordValidation({
|
||||||
|
value,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
errors: string[]
|
||||||
|
}) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
function getErrorMessage(key: PasswordValidatorKey) {
|
||||||
|
switch (key) {
|
||||||
|
case "length":
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "passwordInput.lengthRequirement",
|
||||||
|
defaultMessage: "{min} to {max} characters",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
min: 10,
|
||||||
|
max: 40,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case "hasUppercase":
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "passwordInput.uppercaseRequirement",
|
||||||
|
defaultMessage: "{count} uppercase letter",
|
||||||
|
},
|
||||||
|
{ count: 1 }
|
||||||
|
)
|
||||||
|
case "hasLowercase":
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "passwordInput.lowercaseRequirement",
|
||||||
|
defaultMessage: "{count} lowercase letter",
|
||||||
|
},
|
||||||
|
{ count: 1 }
|
||||||
|
)
|
||||||
|
case "hasNumber":
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "passwordInput.numberRequirement",
|
||||||
|
defaultMessage: "{count} number",
|
||||||
|
},
|
||||||
|
{ count: 1 }
|
||||||
|
)
|
||||||
|
case "hasSpecialChar":
|
||||||
|
return intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "passwordInput.specialCharacterRequirement",
|
||||||
|
defaultMessage: "{count} special character",
|
||||||
|
},
|
||||||
|
{ count: 1 }
|
||||||
|
)
|
||||||
|
case "allowedCharacters":
|
||||||
|
return intl.formatMessage({
|
||||||
|
id: "passwordInput.allowedCharactersRequirement",
|
||||||
|
defaultMessage: "Only allowed characters",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.errors} role="status" id="password-requirements">
|
||||||
|
{Object.entries(passwordValidators).map(([key, { message }]) => (
|
||||||
|
<Typography variant="Label/xsRegular" key={key}>
|
||||||
|
<span className={styles.helpText}>
|
||||||
|
<Icon errorMessage={message} errors={errors} />
|
||||||
|
{getErrorMessage(key as PasswordValidatorKey)}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
errorMessage: string
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Icon({ errorMessage, errors }: IconProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
return errors.includes(errorMessage) ? (
|
||||||
|
<MaterialIcon
|
||||||
|
icon="close"
|
||||||
|
color="Icon/Feedback/Error"
|
||||||
|
size={20}
|
||||||
|
role="img"
|
||||||
|
aria-label={intl.formatMessage({
|
||||||
|
id: "common.error",
|
||||||
|
defaultMessage: "Error",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MaterialIcon
|
||||||
|
icon="check"
|
||||||
|
color="Icon/Feedback/Success"
|
||||||
|
size={20}
|
||||||
|
role="img"
|
||||||
|
aria-label={intl.formatMessage({
|
||||||
|
id: "common.success",
|
||||||
|
defaultMessage: "Success",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,22 +1,32 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Text, TextField } from "react-aria-components"
|
import { TextField } from "react-aria-components"
|
||||||
import { Controller, useFormContext } from "react-hook-form"
|
import {
|
||||||
|
Controller,
|
||||||
|
type RegisterOptions,
|
||||||
|
useFormContext,
|
||||||
|
} from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { passwordValidators } from "@scandic-hotels/common/utils/zod/passwordValidator"
|
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||||
import Caption from "@scandic-hotels/design-system/Caption"
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { Input } from "@scandic-hotels/design-system/Input"
|
import { Input } from "@scandic-hotels/design-system/Input"
|
||||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import { getErrorMessage } from "@/utils/getErrorMessage"
|
import { getErrorMessage } from "@/utils/getErrorMessage"
|
||||||
|
|
||||||
|
import { NewPasswordValidation } from "./NewPasswordValidation"
|
||||||
|
|
||||||
import styles from "./passwordInput.module.css"
|
import styles from "./passwordInput.module.css"
|
||||||
|
|
||||||
import type { PasswordValidatorKey } from "@/types/components/form/newPassword"
|
interface PasswordInputProps
|
||||||
import type { IconProps, PasswordInputProps } from "./passwordInput"
|
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string
|
||||||
|
registerOptions?: RegisterOptions
|
||||||
|
visibilityToggleable?: boolean
|
||||||
|
isNewPassword?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export default function PasswordInput({
|
export default function PasswordInput({
|
||||||
name = "password",
|
name = "password",
|
||||||
@@ -48,6 +58,8 @@ export default function PasswordInput({
|
|||||||
<TextField
|
<TextField
|
||||||
className={className}
|
className={className}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
aria-invalid={!!fieldState.error}
|
||||||
|
aria-describedby="password-error password-requirements"
|
||||||
isDisabled={field.disabled}
|
isDisabled={field.disabled}
|
||||||
isInvalid={fieldState.invalid}
|
isInvalid={fieldState.invalid}
|
||||||
isRequired={!!registerOptions.required}
|
isRequired={!!registerOptions.required}
|
||||||
@@ -85,11 +97,8 @@ export default function PasswordInput({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{visibilityToggleable ? (
|
{visibilityToggleable ? (
|
||||||
<Button
|
<IconButton
|
||||||
type="button"
|
theme="Black"
|
||||||
variant="icon"
|
|
||||||
size="small"
|
|
||||||
intent="tertiary"
|
|
||||||
onClick={() => setIsPasswordVisible((value) => !value)}
|
onClick={() => setIsPasswordVisible((value) => !value)}
|
||||||
aria-label={
|
aria-label={
|
||||||
isPasswordVisible
|
isPasswordVisible
|
||||||
@@ -105,12 +114,11 @@ export default function PasswordInput({
|
|||||||
aria-controls={field.name}
|
aria-controls={field.name}
|
||||||
className={styles.toggleButton}
|
className={styles.toggleButton}
|
||||||
>
|
>
|
||||||
{isPasswordVisible ? (
|
<MaterialIcon
|
||||||
<MaterialIcon icon="visibility_off" />
|
icon={isPasswordVisible ? "visibility_off" : "visibility"}
|
||||||
) : (
|
size={24}
|
||||||
<MaterialIcon icon="visibility" />
|
/>
|
||||||
)}
|
</IconButton>
|
||||||
</Button>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,16 +128,10 @@ export default function PasswordInput({
|
|||||||
|
|
||||||
{isNewPassword ? (
|
{isNewPassword ? (
|
||||||
!field.value && fieldState.error ? (
|
!field.value && fieldState.error ? (
|
||||||
<Caption className={styles.error} fontOnly>
|
<ErrorMessage errorMessage={fieldState.error.message} />
|
||||||
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
|
|
||||||
{getErrorMessage(intl, fieldState.error.message)}
|
|
||||||
</Caption>
|
|
||||||
) : null
|
) : null
|
||||||
) : fieldState.error ? (
|
) : fieldState.error ? (
|
||||||
<Caption className={styles.error} fontOnly>
|
<ErrorMessage errorMessage={fieldState.error.message} />
|
||||||
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
|
|
||||||
{getErrorMessage(intl, fieldState.error.message)}
|
|
||||||
</Caption>
|
|
||||||
) : null}
|
) : null}
|
||||||
</TextField>
|
</TextField>
|
||||||
)
|
)
|
||||||
@@ -138,88 +140,24 @@ export default function PasswordInput({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Icon({ errorMessage, errors }: IconProps) {
|
function ErrorMessage({ errorMessage }: { errorMessage?: string }) {
|
||||||
return errors.includes(errorMessage) ? (
|
|
||||||
<MaterialIcon icon="close" color="Icon/Interactive/Accent" size={20} />
|
|
||||||
) : (
|
|
||||||
<MaterialIcon icon="check" color="Icon/Feedback/Success" size={20} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NewPasswordValidation({
|
|
||||||
value,
|
|
||||||
errors,
|
|
||||||
}: {
|
|
||||||
value: string
|
|
||||||
errors: string[]
|
|
||||||
}) {
|
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
if (!value) return null
|
|
||||||
|
|
||||||
function getErrorMessage(key: PasswordValidatorKey) {
|
|
||||||
switch (key) {
|
|
||||||
case "length":
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "passwordInput.lengthRequirement",
|
|
||||||
defaultMessage: "{min} to {max} characters",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
min: 10,
|
|
||||||
max: 40,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
case "hasUppercase":
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "passwordInput.uppercaseRequirement",
|
|
||||||
defaultMessage: "{count} uppercase letter",
|
|
||||||
},
|
|
||||||
{ count: 1 }
|
|
||||||
)
|
|
||||||
case "hasLowercase":
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "passwordInput.lowercaseRequirement",
|
|
||||||
defaultMessage: "{count} lowercase letter",
|
|
||||||
},
|
|
||||||
{ count: 1 }
|
|
||||||
)
|
|
||||||
case "hasNumber":
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "passwordInput.numberRequirement",
|
|
||||||
defaultMessage: "{count} number",
|
|
||||||
},
|
|
||||||
{ count: 1 }
|
|
||||||
)
|
|
||||||
case "hasSpecialChar":
|
|
||||||
return intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "passwordInput.specialCharacterRequirement",
|
|
||||||
defaultMessage: "{count} special character",
|
|
||||||
},
|
|
||||||
{ count: 1 }
|
|
||||||
)
|
|
||||||
case "allowedCharacters":
|
|
||||||
return intl.formatMessage({
|
|
||||||
id: "passwordInput.allowedCharactersRequirement",
|
|
||||||
defaultMessage: "Only allowed characters",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.errors}>
|
<Typography
|
||||||
{Object.entries(passwordValidators).map(([key, { message }]) => (
|
variant="Body/Supporting text (caption)/smRegular"
|
||||||
<Caption asChild color="black" key={key}>
|
className={styles.error}
|
||||||
<Text className={styles.helpText} slot="description">
|
>
|
||||||
<Icon errorMessage={message} errors={errors} />
|
<p role="alert" id="password-error">
|
||||||
{getErrorMessage(key as PasswordValidatorKey)}
|
<MaterialIcon
|
||||||
</Text>
|
icon="info"
|
||||||
</Caption>
|
color="Icon/Feedback/Error"
|
||||||
))}
|
aria-label={intl.formatMessage({
|
||||||
</div>
|
id: "common.error",
|
||||||
|
defaultMessage: "Error",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{getErrorMessage(intl, errorMessage)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { RegisterOptions } from "react-hook-form"
|
|
||||||
|
|
||||||
export interface PasswordInputProps
|
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
label?: string
|
|
||||||
registerOptions?: RegisterOptions
|
|
||||||
visibilityToggleable?: boolean
|
|
||||||
isNewPassword?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IconProps {
|
|
||||||
errorMessage: string
|
|
||||||
errors: string[]
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,11 @@ import { MaterialSymbol, type MaterialSymbolProps } from './MaterialSymbol'
|
|||||||
import { iconVariants } from '../variants'
|
import { iconVariants } from '../variants'
|
||||||
|
|
||||||
import type { VariantProps } from 'class-variance-authority'
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { HTMLAttributes } from 'react'
|
||||||
|
|
||||||
export interface MaterialIconProps
|
export interface MaterialIconProps
|
||||||
extends Pick<MaterialSymbolProps, 'size' | 'icon' | 'className' | 'style'>,
|
extends Pick<MaterialSymbolProps, 'size' | 'icon' | 'className' | 'style'>,
|
||||||
|
Omit<HTMLAttributes<HTMLSpanElement>, 'color' | 'id'>,
|
||||||
VariantProps<typeof iconVariants> {
|
VariantProps<typeof iconVariants> {
|
||||||
isFilled?: boolean
|
isFilled?: boolean
|
||||||
}
|
}
|
||||||
@@ -18,6 +20,12 @@ export function MaterialIcon({
|
|||||||
...props
|
...props
|
||||||
}: MaterialIconProps) {
|
}: MaterialIconProps) {
|
||||||
const classNames = iconVariants({ className, color })
|
const classNames = iconVariants({ className, color })
|
||||||
|
const { role, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden } = props
|
||||||
|
|
||||||
|
// Automatically decide whether to hide from assistive tech
|
||||||
|
const computedAriaHidden =
|
||||||
|
ariaHidden !== undefined ? ariaHidden : ariaLabel || role ? false : true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// The span is used to prevent the MaterialSymbol from being underlined when used inside a link or button
|
// The span is used to prevent the MaterialSymbol from being underlined when used inside a link or button
|
||||||
<span>
|
<span>
|
||||||
@@ -25,8 +33,9 @@ export function MaterialIcon({
|
|||||||
className={classNames}
|
className={classNames}
|
||||||
data-testid="MaterialIcon"
|
data-testid="MaterialIcon"
|
||||||
size={size}
|
size={size}
|
||||||
{...props}
|
|
||||||
fill={isFilled}
|
fill={isFilled}
|
||||||
|
aria-hidden={computedAriaHidden}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user