Skip to content

Commit

Permalink
Add password requirements popover
Browse files Browse the repository at this point in the history
  • Loading branch information
OKendigelyan committed Sep 26, 2024
1 parent 8c422c1 commit a9e7217
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 26 deletions.
63 changes: 42 additions & 21 deletions apps/web/src/components/PasswordInput/PasswordInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
InputGroup,
type InputProps,
InputRightElement,
useDisclosure,
} from "@chakra-ui/react";
import { usePasswordValidation } from "@umami/components";
import { useState } from "react";
Expand Down Expand Up @@ -41,12 +42,20 @@ export const PasswordInput = <T extends FieldValues, U extends Path<T>>({
validate,
...rest
}: PasswordInputProps<T, U>) => {
const form = useFormContext<T>();
const {
register,
formState: { errors },
} = useFormContext<T>();
} = form;
const [showPassword, setShowPassword] = useState<boolean>(false);
const { validatePasswordStrength, PasswordStrengthBar } = usePasswordValidation({
const { isOpen, onOpen, onClose } = useDisclosure();
const {
validatePasswordStrength,
PasswordStrengthBar,
PasswordRequirementsPopover,
requirements,
score,
} = usePasswordValidation({
inputName,
});

Expand All @@ -69,28 +78,38 @@ export const PasswordInput = <T extends FieldValues, U extends Path<T>>({
}
};

const registerProps = register(inputName, {
required,
minLength:
minLength && required
? {
value: minLength,
message: `Your password must be at least ${minLength} characters long`,
}
: undefined,
validate: handleValidate,
});

return (
<FormControl isInvalid={!!error}>
<FormLabel>{label}</FormLabel>
<InputGroup marginTop="12px">
<Input
aria-label={label}
autoComplete="off"
placeholder={placeholder}
type={showPassword ? "text" : "password"}
{...register(inputName, {
required,
minLength:
minLength && required
? {
value: minLength,
message: `Your password must be at least ${minLength} characters long`,
}
: undefined,
validate: handleValidate,
})}
{...rest}
/>
<InputGroup marginTop="12px" onBlur={onClose}>
<PasswordRequirementsPopover
inputName={inputName}
isOpen={isCheckPasswordStrengthEnabled && isOpen}
requirements={requirements}
score={score}
>
<Input
aria-label={label}
autoComplete="off"
onFocus={onOpen}
placeholder={placeholder}
type={showPassword ? "text" : "password"}
{...registerProps}
{...rest}
/>
</PasswordRequirementsPopover>
<InputRightElement>
<IconButton
color={color("400")}
Expand All @@ -117,3 +136,5 @@ export const PasswordInput = <T extends FieldValues, U extends Path<T>>({
</FormControl>
);
};

export default PasswordInput;
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
]
},
"dependencies": {
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
Expand Down
119 changes: 116 additions & 3 deletions packages/components/src/hooks/usePasswordValidation.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { Box, Flex, Text } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { CheckCircleIcon, WarningIcon } from "@chakra-ui/icons";
import {
Box,
Flex,
List,
ListIcon,
ListItem,
Popover,
PopoverBody,
PopoverContent,
PopoverHeader,
type PopoverProps,
PopoverTrigger,
Text,
} from "@chakra-ui/react";
import { type PropsWithChildren, useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import zxcvbn from "zxcvbn";

Expand All @@ -17,6 +31,20 @@ type UsePasswordValidationProps = {
inputName?: string;
};

type PasswordRequirements = {
length: boolean;
capital: boolean;
number: boolean;
symbol: boolean;
};

type PasswordRequirementsPopoverProps = PropsWithChildren<{
inputName: string;
requirements: PasswordRequirements;
score: number;
}> &
PopoverProps;

const PasswordStrengthBar = ({ score, color, inputName }: PasswordStrengthBarProps) => {
const form = useFormContext();

Expand Down Expand Up @@ -80,13 +108,81 @@ const PasswordStrengthBar = ({ score, color, inputName }: PasswordStrengthBarPro
);
};

export const PasswordRequirementsPopover = ({
children,
requirements,
score,
...props
}: PasswordRequirementsPopoverProps) => (
<Popover autoFocus={false} {...props} placement="right">
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent
zIndex={9999}
background="white"
borderWidth="1.5px"
borderColor="gray.100"
borderRadius="8px"
>
<PopoverHeader borderBottomWidth="1px" borderBottomColor="gray.100">
Password Requirements
</PopoverHeader>
<PopoverBody>
<List spacing={2}>
<ListItem>
<ListIcon
as={requirements.length ? CheckCircleIcon : WarningIcon}
color={requirements.length ? "green.500" : "red.500"}
/>
At least 12 characters
</ListItem>
<ListItem>
<ListIcon
as={requirements.capital ? CheckCircleIcon : WarningIcon}
color={requirements.capital ? "green.500" : "red.500"}
/>
At least one capital letter
</ListItem>
<ListItem>
<ListIcon
as={requirements.number ? CheckCircleIcon : WarningIcon}
color={requirements.number ? "green.500" : "red.500"}
/>
At least one number
</ListItem>
<ListItem>
<ListIcon
as={requirements.symbol ? CheckCircleIcon : WarningIcon}
color={requirements.symbol ? "green.500" : "red.500"}
/>
At least one symbol
</ListItem>
<ListItem>
<ListIcon
as={score === 4 ? CheckCircleIcon : WarningIcon}
color={score === 4 ? "green.500" : "red.500"}
/>
Keep on, make it more complex!
</ListItem>
</List>
</PopoverBody>
</PopoverContent>
</Popover>
);

export const usePasswordValidation = ({
color = DEFAULT_COLOR,
inputName = "password",
}: UsePasswordValidationProps = {}) => {
const [passwordScore, setPasswordScore] = useState(DEFAULT_SCORE);
const form = useFormContext();

const [requirements, setRequirements] = useState<PasswordRequirements>({
length: false,
capital: false,
number: false,
symbol: false,
});

const passwordError = form.formState.errors[inputName];

useEffect(() => {
Expand All @@ -95,14 +191,28 @@ export const usePasswordValidation = ({
}
}, [passwordError]);

const password = form.watch(inputName);

useEffect(() => {
setRequirements({
length: password?.length >= 12,
capital: /[A-Z]/.test(password),
number: /\d/.test(password),
symbol: /[!@#$%^&*(),.?":{}|<>]/.test(password),
});
}, [password]);

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

setPasswordScore(result.score);
const requirementsMeetingPercentage = Object.values(requirements).filter(Boolean).length / 4;

setPasswordScore(Math.round(result.score * requirementsMeetingPercentage));

if (result.score < 4) {
return result.feedback.suggestions.at(-1);
}

return true;
};

Expand All @@ -111,5 +221,8 @@ export const usePasswordValidation = ({
PasswordStrengthBar: (
<PasswordStrengthBar color={color} inputName={inputName} score={passwordScore} />
),
PasswordRequirementsPopover,
requirements,
score: passwordScore,
};
};
7 changes: 5 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit a9e7217

Please sign in to comment.