feat(SW-360): Refactored NewPassword input

This commit is contained in:
Tobias Johansson
2024-09-10 10:33:34 +02:00
committed by Chuma McPhoy
parent 9caa560b8d
commit 9435059097
13 changed files with 222 additions and 83 deletions

View File

@@ -69,6 +69,7 @@ export default function Form({ user }: EditFormProps) {
retypeNewPassword: "",
},
mode: "all",
criteriaMode: "all",
resolver: zodResolver(editProfileSchema),
reValidateMode: "onChange",
})

View File

@@ -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",

View File

@@ -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 (
<Controller
disabled={disabled}
control={control}
name="newPassword"
name={name}
rules={registerOptions}
render={({ field, fieldState }) => {
const messages = (fieldState.error?.message?.split(",") ?? []) as Key[]
render={({ field, fieldState, formState }) => {
const errors = Object.values(formState.errors[name]?.types ?? []).flat()
return (
<TextField
aria-label={ariaLabel}
@@ -39,51 +66,52 @@ export default function NewPassword({
onChange={field.onChange}
validationBehavior="aria"
value={field.value}
type="password"
type={isPasswordVisible ? "text" : "password"}
>
<AriaInputWithLabel
{...field}
aria-labelledby={field.name}
id={field.name}
label={formatMessage({ id: "New password" })}
placeholder={placeholder}
type="password"
/>
<div className={styles.inputWrapper}>
<AriaInputWithLabel
{...field}
aria-labelledby={field.name}
id={field.name}
label={intl.formatMessage({ id: "New password" })}
placeholder={placeholder}
type={isPasswordVisible ? "text" : "password"}
/>
<Button
className={styles.eyeIcon}
type="button"
variant="icon"
size="small"
intent="tertiary"
onClick={() => setPasswordVisible(!isPasswordVisible)}
>
<Image
src={`/_static/img/icons/${isPasswordVisible ? "eye-icon" : "eye-show"}.svg`}
alt="eye"
width={24}
height={24}
/>
</Button>
</div>
{field.value ? (
<div className={styles.errors}>
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<Icon matcher={Key.CHAR_LENGTH} messages={messages} />
10 {formatMessage({ id: "to" })} 40{" "}
{formatMessage({ id: "characters" })}
</Text>
</Caption>
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<Icon matcher={Key.UPPERCASE} messages={messages} />1{" "}
{formatMessage({ id: "uppercase letter" })}
</Text>
</Caption>
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<Icon matcher={Key.NUM} messages={messages} />1{" "}
{formatMessage({ id: "number" })}
</Text>
</Caption>
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<Icon matcher={Key.SPECIAL_CHAR} messages={messages} />1{" "}
{formatMessage({ id: "special character" })}
</Text>
</Caption>
{Object.entries(passwordValidators).map(
([key, { message }]) => (
<Caption asChild color="black" key={key}>
<Text className={styles.helpText} slot="description">
<Icon errorMessage={message} errors={errors} />
{getErrorMessage(key as PasswordValidatorKey)}
</Text>
</Caption>
)
)}
</div>
) : null}
{!field.value && fieldState.error ? (
<Error>
<Text className={styles.helpText} slot="description">
{fieldState.error.message}
</Text>
</Error>
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</TextField>
)
@@ -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) ? (
<CloseIcon color="red" height={20} width={20} />
) : (
<CheckIcon color="green" height={20} width={20} />

View File

@@ -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;
}

View File

@@ -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<HTMLInputElement> {
label?: string
@@ -14,6 +7,6 @@ export interface NewPasswordProps
}
export interface IconProps {
matcher: Key
messages: Key[]
errorMessage: string
errors: string[]
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_348_1004" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_348_1004)">
<path d="M12.0001 15.85C13.2084 15.85 14.2355 15.4271 15.0813 14.5813C15.9272 13.7354 16.3501 12.7083 16.3501 11.5C16.3501 10.2917 15.9272 9.2646 15.0813 8.41876C14.2355 7.57293 13.2084 7.15001 12.0001 7.15001C10.7918 7.15001 9.76468 7.57293 8.91885 8.41876C8.07302 9.2646 7.6501 10.2917 7.6501 11.5C7.6501 12.7083 8.07302 13.7354 8.91885 14.5813C9.76468 15.4271 10.7918 15.85 12.0001 15.85ZM12.003 14.15C11.2677 14.15 10.6418 13.8927 10.1251 13.378C9.60843 12.8632 9.3501 12.2382 9.3501 11.503C9.3501 10.7677 9.60745 10.1417 10.1221 9.62501C10.6369 9.10835 11.2619 8.85001 11.9971 8.85001C12.7324 8.85001 13.3584 9.10736 13.8751 9.62206C14.3918 10.1368 14.6501 10.7618 14.6501 11.4971C14.6501 12.2324 14.3927 12.8583 13.878 13.375C13.3633 13.8917 12.7383 14.15 12.003 14.15ZM12.002 18.8C9.82572 18.8 7.83968 18.2125 6.04385 17.0375C4.24801 15.8625 2.8251 14.3167 1.7751 12.4C1.69176 12.2583 1.63135 12.1124 1.59385 11.9622C1.55635 11.812 1.5376 11.6578 1.5376 11.4997C1.5376 11.3416 1.55635 11.1875 1.59385 11.0375C1.63135 10.8875 1.69176 10.7417 1.7751 10.6C2.8251 8.68335 4.24739 7.13751 6.04197 5.96251C7.83657 4.78751 9.82199 4.20001 11.9982 4.20001C14.1745 4.20001 16.1605 4.78751 17.9563 5.96251C19.7522 7.13751 21.1751 8.68335 22.2251 10.6C22.3084 10.7417 22.3688 10.8876 22.4063 11.0378C22.4438 11.1881 22.4626 11.3422 22.4626 11.5003C22.4626 11.6585 22.4438 11.8125 22.4063 11.9625C22.3688 12.1125 22.3084 12.2583 22.2251 12.4C21.1751 14.3167 19.7528 15.8625 17.9582 17.0375C16.1636 18.2125 14.1782 18.8 12.002 18.8ZM12 16.925C13.8584 16.925 15.5647 16.4375 17.1188 15.4625C18.673 14.4875 19.8584 13.1667 20.6751 11.5C19.8584 9.83335 18.673 8.51251 17.1189 7.53751C15.5648 6.56251 13.8585 6.07501 12.0001 6.07501C10.1418 6.07501 8.43551 6.56251 6.88135 7.53751C5.32718 8.51251 4.14176 9.83335 3.3251 11.5C4.14176 13.1667 5.32716 14.4875 6.8813 15.4625C8.43541 16.4375 10.1417 16.925 12 16.925Z" fill="#A8A4A2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -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,
})