Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enhance password validation with detailed requirements and visu… #2396

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
9 changes: 4 additions & 5 deletions apps/web/src/components/PasswordInput/PasswordInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const PasswordInput = <T extends FieldValues, U extends Path<T>>({
<FormLabel>{label}</FormLabel>
<InputGroup marginTop="12px">
<Input
paddingRight="60px"
color={color("400")}
fontSize="18px"
_focusWithin={{
Expand Down Expand Up @@ -108,12 +109,10 @@ export const PasswordInput = <T extends FieldValues, U extends Path<T>>({
/>
</InputRightElement>
</InputGroup>
{isStrengthCheckEnabled && PasswordStrengthBar}
{error && (
<FormErrorMessage data-testid={`${rest["data-testid"]}-error`}>
{errorMessage}
</FormErrorMessage>
{error?.message && (
<FormErrorMessage data-testid="password-input-error">{errorMessage}</FormErrorMessage>
)}
{isStrengthCheckEnabled && PasswordStrengthBar}
</FormControl>
);
};
Expand Down
2 changes: 1 addition & 1 deletion apps/web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import svgr from "vite-plugin-svgr";
import sri from "vite-plugin-sri";
import svgr from "vite-plugin-svgr";

// https://vitejs.dev/config/
export default defineConfig({
Expand Down
27 changes: 27 additions & 0 deletions packages/components/src/assets/icons/CheckmarkIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Icon, type IconProps } from "@chakra-ui/react";

export const CheckmarkIcon = (props: IconProps) => (
<Icon
width="18px"
height="18px"
fill="none"
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clip-path="url(#clip0_415_12430)">
<path
d="M6 9.25L7.84615 11.25L12 6.75M15.75 9C15.75 12.7279 12.7279 15.75 9 15.75C5.27208 15.75 2.25 12.7279 2.25 9C2.25 5.27208 5.27208 2.25 9 2.25C12.7279 2.25 15.75 5.27208 15.75 9Z"
stroke="currentcolor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.2"
/>
</g>
<defs>
<clipPath id="clip0_415_12430">
<rect width="18" height="18" fill="white" />
</clipPath>
</defs>
</Icon>
);
1 change: 1 addition & 0 deletions packages/components/src/assets/icons/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CheckmarkIcon } from "./CheckmarkIcon";
172 changes: 96 additions & 76 deletions packages/components/src/hooks/usePasswordValidation.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import { Box, Flex, Text } from "@chakra-ui/react";
import { List, ListIcon, ListItem, Text } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { z } from "zod";
import zxcvbn from "zxcvbn";

import { CheckmarkIcon } from "../assets/icons";

export const DEFAULT_MIN_LENGTH = 12;

const DEFAULT_SCORE = 0;
const DEFAULT_COLOR = "gray.100";
const DEFAULT_PASSWORD_FIELD_NAME = "password";
type ValidationPath = "minLength" | "uppercase" | "number" | "special" | "simplicity";

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

type PasswordStrengthBarProps = {
score: number;
color: string;
hasError: boolean;
requirements: Requirement[];
};

type UsePasswordValidationProps = {
Expand All @@ -22,104 +26,120 @@ type UsePasswordValidationProps = {
minLength?: number;
};

const PASSWORD_REQUIREMENTS_COUNT = 4;
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(/[!@#$%^&*(),.?":{}|<>]/, {
message: "Password must contain at least one special character",
});

const PasswordStrengthBar = ({ score, color, hasError }: PasswordStrengthBarProps) => {
const colors = [color, "red.500", "yellow.500", "green.500"];

const getSectionColor = (index: number) => {
switch (score) {
case 1:
case 2:
return index === 0 ? colors[1] : colors[0];
case 3:
return index <= 1 ? colors[2] : colors[0];
case 4:
return colors[3];
default:
return colors[0];
}
};

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

return (
<Flex flexDirection="column" gap="8px" marginTop="12px">
<Flex gap="4px" height="6px">
{Array.from({ length: 3 }).map((_, index) => (
<Box
key={index}
flex="1"
background={getSectionColor(index)}
borderRadius="8px"
transition="background-color 0.2s ease"
/>
))}
</Flex>
{showPasswordStrengthText && (
<Text lineHeight="normal" data-testid="password-strength-text" size="sm">
Your password is strong
.refine(value => value.length >= minLength, {
path: ["minLength"],
})
.refine(value => /[A-Z]/.test(value), {
path: ["uppercase"],
})
.refine(value => /\d/.test(value), {
path: ["number"],
})
.refine(value => /[!@#$%^&*(),.?":{}|<>]/.test(value), {
path: ["special"],
})
.refine(
value => {
const score = zxcvbn(value).score;

return score > 3;
},
{
path: ["simplicity"],
}
);

const PasswordStrengthBar = ({ requirements }: PasswordStrengthBarProps) => (
<List marginTop="12px" spacing="8px">
{requirements.map(({ message, path, passed }) => (
<ListItem
key={path}
alignItems="flex-start"
display="flex"
data-testid={`${path}-${passed ? "passed" : "failed"}`}
>
<ListIcon as={CheckmarkIcon} boxSize="18px" color={passed ? "green" : "gray.400"} />
<Text color="gray.700" size="md">
{message}
</Text>
)}
</Flex>
);
};
</ListItem>
))}
</List>
);

const DEFAULT_REQUIREMENTS: Requirement[] = [
{
message: `Password must be at least ${DEFAULT_MIN_LENGTH} characters long`,
path: "minLength",
passed: false,
},
{
message: "Password must contain at least one uppercase letter",
path: "uppercase",
passed: false,
},
{
message: "Password must contain at least one number",
path: "number",
passed: false,
},
{
message: "Password must contain at least one special character",
path: "special",
passed: false,
},
{
message: "Avoid common passwords, simple patterns and repeated characters",
path: "simplicity",
passed: false,
},
];

export const usePasswordValidation = ({
color = DEFAULT_COLOR,
inputName = DEFAULT_PASSWORD_FIELD_NAME,
minLength = DEFAULT_MIN_LENGTH,
inputName = "password",
}: UsePasswordValidationProps = {}) => {
const [passwordScore, setPasswordScore] = useState(DEFAULT_SCORE);
const [requirements, setRequirements] = useState<Requirement[]>(DEFAULT_REQUIREMENTS);

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) {
setPasswordScore(DEFAULT_SCORE);
if (hasRequiredError || !isDirty) {
setRequirements(DEFAULT_REQUIREMENTS);
}
}, [isDirty, passwordError]);
}, [isDirty, hasRequiredError]);

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

try {
getPasswordSchema(minLength).parse(value);
setRequirements(requirements.map(requirement => ({ ...requirement, passed: true })));
} catch (e) {
if (e instanceof z.ZodError) {
schemaErrors = e.errors.length;
return e.errors[0].message;
const errorPaths = new Set(e.errors.map(error => error.path[0]));
setRequirements(prev =>
prev.map(requirement => ({
...requirement,
passed: !errorPaths.has(requirement.path),
}))
);

return false;
}
} finally {
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 true;
};

return {
validatePasswordStrength,
PasswordStrengthBar: (
<PasswordStrengthBar color={color} hasError={!!passwordError} score={passwordScore} />
),
PasswordStrengthBar: <PasswordStrengthBar requirements={requirements} />,
};
};
Loading