diff --git a/components/Forms/Edit/Profile/index.tsx b/components/Forms/Edit/Profile/index.tsx index 33f93e0fd..89097ae31 100644 --- a/components/Forms/Edit/Profile/index.tsx +++ b/components/Forms/Edit/Profile/index.tsx @@ -69,6 +69,7 @@ export default function Form({ user }: EditFormProps) { retypeNewPassword: "", }, mode: "all", + criteriaMode: "all", resolver: zodResolver(editProfileSchema), reValidateMode: "onChange", }) diff --git a/components/Forms/Edit/Profile/schema.ts b/components/Forms/Edit/Profile/schema.ts index 7330214ff..bf4e374cc 100644 --- a/components/Forms/Edit/Profile/schema.ts +++ b/components/Forms/Edit/Profile/schema.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { Key } from "@/components/TempDesignSystem/Form/NewPassword/newPassword" +import { passwordValidator } from "@/utils/passwordValidator" import { phoneValidator } from "@/utils/phoneValidator" const countryRequiredMsg = "Country is required" @@ -26,7 +26,7 @@ export const editProfileSchema = z ), password: z.string().optional(), - newPassword: z.string().optional(), + newPassword: passwordValidator(), retypeNewPassword: z.string().optional(), }) .superRefine((data, ctx) => { @@ -55,29 +55,6 @@ export const editProfileSchema = z } } - if (data.newPassword) { - const msgs = [] - if (data.newPassword.length < 10 || data.newPassword.length > 40) { - msgs.push(Key.CHAR_LENGTH) - } - if (!data.newPassword.match(/[A-Z]/g)) { - msgs.push(Key.UPPERCASE) - } - if (!data.newPassword.match(/[0-9]/g)) { - msgs.push(Key.NUM) - } - if (!data.newPassword.match(/[^A-Za-z0-9]/g)) { - msgs.push(Key.SPECIAL_CHAR) - } - if (msgs.length) { - ctx.addIssue({ - code: "custom", - message: msgs.join(","), - path: ["newPassword"], - }) - } - } - if (data.newPassword && !data.retypeNewPassword) { ctx.addIssue({ code: "custom", diff --git a/components/TempDesignSystem/Form/NewPassword/index.tsx b/components/TempDesignSystem/Form/NewPassword/index.tsx index 3c9950e1f..558247e04 100644 --- a/components/TempDesignSystem/Form/NewPassword/index.tsx +++ b/components/TempDesignSystem/Form/NewPassword/index.tsx @@ -1,33 +1,60 @@ "use client" + import { Text, TextField } from "react-aria-components" import { Controller, useFormContext } from "react-hook-form" import { useIntl } from "react-intl" -import { CheckIcon, CloseIcon } from "@/components/Icons" -import Error from "@/components/TempDesignSystem/Form/ErrorMessage/Error" import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel" -import Caption from "@/components/TempDesignSystem/Text/Caption" +import Image from "next/image" +import { useState } from "react" -import { type IconProps, Key, type NewPasswordProps } from "./newPassword" +import { CheckIcon, CloseIcon, InfoCircleIcon } from "@/components/Icons" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import { + PasswordValidatorKey, + passwordValidators, +} from "@/utils/passwordValidator" + +import Button from "../../Button" +import { IconProps, type NewPasswordProps } from "./newPassword" import styles from "./newPassword.module.css" export default function NewPassword({ + name = "newPassword", "aria-label": ariaLabel, disabled = false, placeholder = "", registerOptions = {}, + label, }: NewPasswordProps) { const { control } = useFormContext() - const { formatMessage } = useIntl() + const intl = useIntl() + const [isPasswordVisible, setPasswordVisible] = useState(false) + + function getErrorMessage(key: PasswordValidatorKey) { + switch (key) { + case "length": + return `10 ${intl.formatMessage({ id: "to" })} 40 ${intl.formatMessage({ id: "characters" })}` + case "hasUppercase": + return `1 ${intl.formatMessage({ id: "uppercase letter" })}` + case "hasLowercase": + return `1 ${intl.formatMessage({ id: "lowercase letter" })}` + case "hasNumber": + return `1 ${intl.formatMessage({ id: "number" })}` + case "hasSpecialChar": + return `1 ${intl.formatMessage({ id: "special character" })}` + } + } + return ( { - const messages = (fieldState.error?.message?.split(",") ?? []) as Key[] + render={({ field, fieldState, formState }) => { + const errors = Object.values(formState.errors[name]?.types ?? []).flat() return ( - +
+ + +
{field.value ? (
- - - - 10 {formatMessage({ id: "to" })} 40{" "} - {formatMessage({ id: "characters" })} - - - - - 1{" "} - {formatMessage({ id: "uppercase letter" })} - - - - - 1{" "} - {formatMessage({ id: "number" })} - - - - - 1{" "} - {formatMessage({ id: "special character" })} - - + {Object.entries(passwordValidators).map( + ([key, { message }]) => ( + + + + {getErrorMessage(key as PasswordValidatorKey)} + + + ) + )}
) : null} {!field.value && fieldState.error ? ( - - - {fieldState.error.message} - - + + + {fieldState.error.message} + ) : null}
) @@ -92,8 +120,8 @@ export default function NewPassword({ ) } -function Icon({ matcher, messages }: IconProps) { - return messages.includes(matcher) ? ( +function Icon({ errorMessage, errors }: IconProps) { + return errors.includes(errorMessage) ? ( ) : ( diff --git a/components/TempDesignSystem/Form/NewPassword/newPassword.module.css b/components/TempDesignSystem/Form/NewPassword/newPassword.module.css index 7b0c4509f..7d57c6850 100644 --- a/components/TempDesignSystem/Form/NewPassword/newPassword.module.css +++ b/components/TempDesignSystem/Form/NewPassword/newPassword.module.css @@ -1,12 +1,90 @@ +.container { + align-content: center; + background-color: var(--Main-Grey-White); + border-color: var(--Scandic-Beige-40); + border-style: solid; + border-width: 1px; + border-radius: var(--Corner-radius-Medium); + display: grid; + height: 60px; + padding: var(--Spacing-x1) var(--Spacing-x2); + transition: border-color 200ms ease; + position: relative; +} + +.container:has(.input:active, .input:focus) { + border-color: var(--Scandic-Blue-90); +} + +.container:has(.input:disabled) { + background-color: var(--Main-Grey-10); + border: none; + color: var(--Main-Grey-40); +} + +.container:has(.input[data-invalid="true"], .input[aria-invalid="true"]) { + border-color: var(--Scandic-Red-60); +} + +.input { + background: none; + border: none; + color: var(--Main-Grey-100); + height: 18px; + margin: 0; + order: 2; + overflow: visible; + padding: 0; +} + +.input:not(:active, :focus):placeholder-shown { + height: 0px; + transition: height 150ms ease; +} + +.input:focus, +.input:focus:placeholder-shown, +.input:active, +.input:active:placeholder-shown { + height: 18px; + transition: height 150ms ease; + outline: none; +} + +.input:disabled { + color: var(--Main-Grey-40); +} + .helpText { align-items: flex-start; display: flex; gap: var(--Spacing-x-half); } +.error { + align-items: center; + color: var(--Scandic-Red-60); + display: flex; + gap: var(--Spacing-x-half); + margin: var(--Spacing-x1) 0 0; +} + .errors { display: flex; flex-wrap: wrap; gap: var(--Spacing-x-one-and-half) var(--Spacing-x1); padding-top: var(--Spacing-x1); } + +.eyeIcon { + position: absolute; + right: var(--Spacing-x2); + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; +} + +.inputWrapper { + position: relative; +} diff --git a/components/TempDesignSystem/Form/NewPassword/newPassword.ts b/components/TempDesignSystem/Form/NewPassword/newPassword.ts index d9d44a20b..e6835cb58 100644 --- a/components/TempDesignSystem/Form/NewPassword/newPassword.ts +++ b/components/TempDesignSystem/Form/NewPassword/newPassword.ts @@ -1,12 +1,5 @@ import type { RegisterOptions } from "react-hook-form" -export enum Key { - CHAR_LENGTH = "CHAR_LENGTH", - NUM = "NUM", - SPECIAL_CHAR = "SPECIAL_CHAR", - UPPERCASE = "UPPERCASE", -} - export interface NewPasswordProps extends React.InputHTMLAttributes { label?: string @@ -14,6 +7,6 @@ export interface NewPasswordProps } export interface IconProps { - matcher: Key - messages: Key[] + errorMessage: string + errors: string[] } diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 76237d5a5..57d493bf9 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -138,6 +138,7 @@ "Log in here": "Log ind her", "Log in/Join": "Log på/Tilmeld dig", "Log out": "Log ud", + "lowercase letter": "lille bogstav", "Main menu": "Hovedmenu", "Manage preferences": "Administrer præferencer", "Map": "Kort", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 742890d7c..ffb0f018b 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -138,6 +138,7 @@ "Log in here": "Hier einloggen", "Log in/Join": "Log in/Anmelden", "Log out": "Ausloggen", + "lowercase letter": "Kleinbuchstabe", "Main menu": "Hauptmenü", "Manage preferences": "Verwalten von Voreinstellungen", "Map": "Karte", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index fa28c7399..fd989d49d 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -138,6 +138,7 @@ "Log in here": "Log in here", "Log in/Join": "Log in/Join", "Log out": "Log out", + "lowercase letter": "lowercase letter", "Main menu": "Main menu", "Manage preferences": "Manage preferences", "Map": "Map", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index ec0b1ee0c..716a58765 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -138,6 +138,7 @@ "Log in here": "Kirjaudu sisään", "Log in/Join": "Kirjaudu sisään/Liittyä", "Log out": "Kirjaudu ulos", + "lowercase letter": "pien kirjain", "Main menu": "Päävalikko", "Manage preferences": "Asetusten hallinta", "Map": "Kartta", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 4a7ab2908..d422258a6 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -137,6 +137,7 @@ "Log in here": "Logg inn her", "Log in/Join": "Logg på/Bli med", "Log out": "Logg ut", + "lowercase letter": "liten bokstav", "Main menu": "Hovedmeny", "Manage preferences": "Administrer preferanser", "Map": "Kart", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 28c7a9889..51543e59c 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -138,6 +138,7 @@ "Log in here": "Logga in här", "Log in/Join": "Logga in/Gå med", "Log out": "Logga ut", + "lowercase letter": "liten bokstav", "Main menu": "Huvudmeny", "Manage preferences": "Hantera inställningar", "Map": "Karta", diff --git a/public/_static/img/icons/eye-show.svg b/public/_static/img/icons/eye-show.svg new file mode 100644 index 000000000..f14b33977 --- /dev/null +++ b/public/_static/img/icons/eye-show.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/utils/passwordValidator.ts b/utils/passwordValidator.ts new file mode 100644 index 000000000..8a8d74778 --- /dev/null +++ b/utils/passwordValidator.ts @@ -0,0 +1,48 @@ +import { z } from "zod" + +export const passwordValidators = { + length: { + matcher: (password: string) => + password.length >= 10 && password.length <= 40, + message: "10 to 40 characters", + }, + hasUppercase: { + matcher: (password: string) => /[A-Z]/.test(password), + message: "1 uppercase letter", + }, + hasLowercase: { + matcher: (password: string) => /[a-z]/.test(password), + message: "1 lowercase letter", + }, + hasNumber: { + matcher: (password: string) => /[0-9]/.test(password), + message: "1 number", + }, + hasSpecialChar: { + matcher: (password: string) => + /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+/.test(password), + message: "1 special character", + }, +} + +export type PasswordValidatorKey = keyof typeof passwordValidators + +export const passwordValidator = (msg = "Required field") => + z + .string() + .min(1, msg) + .refine(passwordValidators.length.matcher, { + message: passwordValidators.length.message, + }) + .refine(passwordValidators.hasUppercase.matcher, { + message: passwordValidators.hasUppercase.message, + }) + .refine(passwordValidators.hasLowercase.matcher, { + message: passwordValidators.hasLowercase.message, + }) + .refine(passwordValidators.hasNumber.matcher, { + message: passwordValidators.hasNumber.message, + }) + .refine(passwordValidators.hasSpecialChar.matcher, { + message: passwordValidators.hasSpecialChar.message, + })