Skip to content

Commit

Permalink
feat: enhance password validation with detailed requirements and visu…
Browse files Browse the repository at this point in the history
…al feedback
  • Loading branch information
OKendigelyan committed Feb 18, 2025
1 parent 816f792 commit c3d6470
Showing 1 changed file with 107 additions and 22 deletions.
129 changes: 107 additions & 22 deletions packages/components/src/hooks/usePasswordValidation.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { Box, Flex, Text } from "@chakra-ui/react";
import { Box, Collapse, Flex, List, ListItem, Text } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { z } from "zod";
import { type ZodIssue, z } from "zod";
import zxcvbn from "zxcvbn";

export const DEFAULT_MIN_LENGTH = 12;

const DEFAULT_SCORE = 0;
const DEFAULT_COLOR = "gray.100";
const DEFAULT_PASSWORD_FIELD_NAME = "password";
const PASSWORD_REQUIREMENTS_COUNT = 4;

// Types
type ValidationPath = "minLength" | "uppercase" | "number" | "special";

type Requirement = {
message: string;
path: ValidationPath;
};

type PasswordStrengthBarProps = {
score: number;
color: string;
hasError: boolean;
errors: ZodIssue[];
hasRequiredError: boolean;
};

type UsePasswordValidationProps = {
Expand All @@ -22,18 +31,68 @@ type UsePasswordValidationProps = {
minLength?: number;
};

const PASSWORD_REQUIREMENTS_COUNT = 4;
const REQUIREMENTS: Requirement[] = [
{
message: `Password must be at least ${DEFAULT_MIN_LENGTH} characters long`,
path: "minLength",
},
{
message: "Password must contain at least one uppercase letter",
path: "uppercase",
},
{
message: "Password must contain at least one number",
path: "number",
},
{
message: "Password must contain at least one special character",
path: "special",
},
];

const RoundStatusDot = ({ background }: { background: string }) => (
<Box
display="inline-block"
width="8px"
height="8px"
marginRight="5px"
background={background}
borderRadius="100%"
/>
);

const getPasswordSchema = (minLength: number) =>
z
.string()
.min(minLength, { message: `Password must be at least ${minLength} characters long` })
.regex(/[A-Z]/, { message: "Password must contain at least one uppercase letter" })
.regex(/\d/, { message: "Password must contain at least one number" })
.regex(/[!@#$%^&*(),.?":{}|<>]/, {
.refine(value => value.length >= minLength, {
message: `Password must be at least ${minLength} characters long`,
path: ["minLength"],
})
.refine(value => /[A-Z]/.test(value), {
message: "Password must contain at least one uppercase letter",
path: ["uppercase"],
})
.refine(value => /\d/.test(value), {
message: "Password must contain at least one number",
path: ["number"],
})
.refine(value => /[!@#$%^&*(),.?":{}|<>]/.test(value), {
message: "Password must contain at least one special character",
path: ["special"],
});

const PasswordStrengthBar = ({ score, color, hasError }: PasswordStrengthBarProps) => {
const PasswordStrengthBar = ({
score,
color,
errors,
hasRequiredError,
}: PasswordStrengthBarProps) => {
const isPasswordStrong = !errors.length && score === 4;
const shouldShowRequirements = !!errors.length && !hasRequiredError;

const checkRequirement = (path: ValidationPath) =>
!errors.some(error => error.path.includes(path));

const colors = [color, "red.500", "yellow.500", "green.500"];

const getSectionColor = (index: number) => {
Expand All @@ -50,8 +109,6 @@ const PasswordStrengthBar = ({ score, color, hasError }: PasswordStrengthBarProp
}
};

const showPasswordStrengthText = !hasError && score === 4;

return (
<Flex flexDirection="column" gap="8px" marginTop="12px">
<Flex gap="4px" height="6px">
Expand All @@ -65,11 +122,30 @@ const PasswordStrengthBar = ({ score, color, hasError }: PasswordStrengthBarProp
/>
))}
</Flex>
{showPasswordStrengthText && (

{isPasswordStrong && (
<Text lineHeight="normal" data-testid="password-strength-text" size="sm">
Your password is strong
</Text>
)}

<Collapse animateOpacity in={shouldShowRequirements}>
<List>
{REQUIREMENTS.map(({ message, path }) => (
<ListItem
key={path}
alignItems="center"
display="flex"
data-testid={`${path}-${checkRequirement(path) ? "passed" : "failed"}`}
>
<RoundStatusDot background={checkRequirement(path) ? "green.500" : "red.300"} />
<Text color={checkRequirement(path) ? "green.500" : "red.300"} fontSize="sm">
{message}
</Text>
</ListItem>
))}
</List>
</Collapse>
</Flex>
);
};
Expand All @@ -80,37 +156,41 @@ export const usePasswordValidation = ({
minLength = DEFAULT_MIN_LENGTH,
}: UsePasswordValidationProps = {}) => {
const [passwordScore, setPasswordScore] = useState(DEFAULT_SCORE);
const [passwordErrors, setPasswordErrors] = useState<ZodIssue[]>([]);

const {
formState: { errors, isDirty },
} = useFormContext();

const passwordError = errors[inputName];
const hasRequiredError = passwordError?.type === "required";

useEffect(() => {
// Set password score to default if the field is empty or the form was reset
if (passwordError?.type === "required" || !isDirty) {
if (hasRequiredError || !isDirty) {
setPasswordScore(DEFAULT_SCORE);
}
}, [isDirty, passwordError]);
}, [isDirty, hasRequiredError]);

const validatePasswordStrength = (value: string) => {
const result = zxcvbn(value);
let schemaErrors = 0;

try {
getPasswordSchema(minLength).parse(value);
setPasswordErrors([]);
} catch (e) {
if (e instanceof z.ZodError) {
schemaErrors = e.errors.length;
return e.errors[0].message;
setPasswordErrors(e.errors);
return false;
}
} finally {
const requirementsMeetingPercentage = (PASSWORD_REQUIREMENTS_COUNT - schemaErrors) / 4;
setPasswordScore(Math.ceil(result.score * requirementsMeetingPercentage));
}

const requirementsMeetingPercentage = (PASSWORD_REQUIREMENTS_COUNT - schemaErrors) / 4;
setPasswordScore(Math.ceil(result.score * requirementsMeetingPercentage));

if (result.score < 4) {
return result.feedback.suggestions.at(-1) ?? "Keep on, make the password more complex!";
return false;
}

return true;
Expand All @@ -119,7 +199,12 @@ export const usePasswordValidation = ({
return {
validatePasswordStrength,
PasswordStrengthBar: (
<PasswordStrengthBar color={color} hasError={!!passwordError} score={passwordScore} />
<PasswordStrengthBar
color={color}
errors={passwordErrors}
hasRequiredError={hasRequiredError}
score={passwordScore}
/>
),
};
};

0 comments on commit c3d6470

Please sign in to comment.