From 234f58648445f768c30d660220943684db0beab2 Mon Sep 17 00:00:00 2001 From: Oleg Chendighelean Date: Thu, 26 Sep 2024 18:23:10 +0100 Subject: [PATCH] Add password requirements check --- .../beacon/SignPayloadRequestModal.test.tsx | 1 - .../PasswordInput/PasswordInput.tsx | 29 +++++---- packages/components/package.json | 2 + .../src/hooks/usePasswordValidation.tsx | 65 ++++++++++--------- pnpm-lock.yaml | 13 +++- 5 files changed, 63 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/utils/beacon/SignPayloadRequestModal.test.tsx b/apps/desktop/src/utils/beacon/SignPayloadRequestModal.test.tsx index 62b95cce56..3600c8f2cf 100644 --- a/apps/desktop/src/utils/beacon/SignPayloadRequestModal.test.tsx +++ b/apps/desktop/src/utils/beacon/SignPayloadRequestModal.test.tsx @@ -25,7 +25,6 @@ const request: SignPayloadRequestOutput = { }; const account = mockMnemonicAccount(1); -const password = "Qwerty123123!23vcxz"; let store: UmamiStore; diff --git a/apps/web/src/components/PasswordInput/PasswordInput.tsx b/apps/web/src/components/PasswordInput/PasswordInput.tsx index f59a223e27..7e64db1503 100644 --- a/apps/web/src/components/PasswordInput/PasswordInput.tsx +++ b/apps/web/src/components/PasswordInput/PasswordInput.tsx @@ -41,10 +41,11 @@ export const PasswordInput = >({ validate, ...rest }: PasswordInputProps) => { + const form = useFormContext(); const { register, formState: { errors }, - } = useFormContext(); + } = form; const [showPassword, setShowPassword] = useState(false); const { validatePasswordStrength, PasswordStrengthBar } = usePasswordValidation({ inputName, @@ -69,6 +70,18 @@ export const PasswordInput = >({ } }; + const registerProps = register(inputName, { + required, + minLength: + minLength && required + ? { + value: minLength, + message: `Your password must be at least ${minLength} characters long`, + } + : undefined, + validate: handleValidate, + }); + return ( {label} @@ -78,17 +91,7 @@ export const PasswordInput = >({ 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, - })} + {...registerProps} {...rest} /> @@ -117,3 +120,5 @@ export const PasswordInput = >({ ); }; + +export default PasswordInput; diff --git a/packages/components/package.json b/packages/components/package.json index 278512f2f3..9d7b95e98a 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -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", @@ -83,6 +84,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.52.2", "react-remove-scroll": "^2.6.0", + "zod": "^3.23.8", "zxcvbn": "^4.4.2" } } diff --git a/packages/components/src/hooks/usePasswordValidation.tsx b/packages/components/src/hooks/usePasswordValidation.tsx index 7362a1c994..bff4cb345a 100644 --- a/packages/components/src/hooks/usePasswordValidation.tsx +++ b/packages/components/src/hooks/usePasswordValidation.tsx @@ -1,6 +1,7 @@ import { Box, Flex, Text } from "@chakra-ui/react"; import { useEffect, useState } from "react"; import { useFormContext } from "react-hook-form"; +import { z } from "zod"; import zxcvbn from "zxcvbn"; const DEFAULT_SCORE = 0; @@ -9,7 +10,7 @@ const DEFAULT_COLOR = "gray.100"; type PasswordStrengthBarProps = { score: number; color: string; - inputName: string; + hasError: boolean; }; type UsePasswordValidationProps = { @@ -17,11 +18,18 @@ type UsePasswordValidationProps = { inputName?: string; }; -const PasswordStrengthBar = ({ score, color, inputName }: PasswordStrengthBarProps) => { - const form = useFormContext(); - +const PASSWORD_REQUIREMENTS_COUNT = 4; +const passwordSchema = z + .string() + .min(12, { message: "Password must be at least 12 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 passwordError = form.formState.errors[inputName]; const getColor = (index: number) => { switch (score) { @@ -37,29 +45,10 @@ const PasswordStrengthBar = ({ score, color, inputName }: PasswordStrengthBarPro } }; - const getText = () => { - switch (score) { - case 1: - case 2: - return "Weak"; - case 3: - return "Medium"; - case 4: - return "Strong"; - default: - return; - } - }; - - const text = getText(); + const showPasswordStrengthText = !hasError && score === 4; return ( - + {Array.from({ length: 3 }).map((_, index) => ( ))} - {!passwordError && text && ( + {showPasswordStrengthText && ( - Your password is {text} + Your password is strong )} @@ -97,19 +86,31 @@ export const usePasswordValidation = ({ const validatePasswordStrength = (value: string) => { const result = zxcvbn(value); - - setPasswordScore(result.score); + let schemaErrors = 0; + + try { + passwordSchema.parse(value); + } catch (e) { + if (e instanceof z.ZodError) { + schemaErrors = e.errors.length; + return e.errors[0].message; + } + } finally { + const requirementsMeetingPercentage = (PASSWORD_REQUIREMENTS_COUNT - schemaErrors) / 4; + setPasswordScore(Math.ceil(result.score * requirementsMeetingPercentage)); + } if (result.score < 4) { - return result.feedback.suggestions.at(-1); + return result.feedback.suggestions.at(-1) ?? "Keep on, make the password more complex!"; } + return true; }; return { validatePasswordStrength, PasswordStrengthBar: ( - + ), }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fbd5aae12..e47f2de1cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,7 +14,7 @@ importers: devDependencies: jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.4)) + version: 29.7.0(@types/node@22.1.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.1.0)(typescript@5.5.4)) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -904,6 +904,9 @@ importers: packages/components: dependencies: + '@chakra-ui/icons': + specifier: ^2.1.1 + version: 2.1.1(@chakra-ui/system@2.6.2(@emotion/react@11.13.3(@types/react@18.3.10)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@18.3.10)(react@18.3.1))(@types/react@18.3.10)(react@18.3.1))(react@18.3.1))(react@18.3.1) '@chakra-ui/react': specifier: ^2.8.2 version: 2.8.2(@emotion/react@11.13.3(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(framer-motion@11.9.0(@emotion/is-prop-valid@1.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -943,6 +946,12 @@ importers: react-remove-scroll: specifier: ^2.6.0 version: 2.6.0(@types/react@18.3.11)(react@18.3.1) + zod: + specifier: ^3.23.8 + version: 3.23.8 + zxcvbn: + specifier: ^4.4.2 + version: 4.4.2 devDependencies: '@babel/core': specifier: ^7.25.7 @@ -1009,7 +1018,7 @@ importers: version: 8.57.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.1.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.1.0)(typescript@5.5.4)) + version: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.4)) lodash: specifier: ^4.17.21 version: 4.17.21