diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index bb84ff67..72fe24e3 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -7,9 +7,9 @@ import { useRouter } from "next/navigation"; import LinkText from "@/components/link-text"; import PasswordCriteria from "@/features/auth/components/password-criteria"; +import PasswordValidation from "@/features/auth/components/password-validation"; import TextInputField from "@/features/auth/components/text-input-field"; import ActionButton from "@/features/button/components/action"; -import { useDebounce } from "@/lib/hooks/use-debounce"; import formatApiError from "@/lib/utils/api/format-api-error"; export default function Page() { @@ -17,48 +17,16 @@ export default function Page() { const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordCriteria, setPasswordCriteria] = useState({}); + const [showPasswordCriteria, setShowPasswordCriteria] = useState(false); const router = useRouter(); function passwordIsStrong() { - return Object.keys(passwordCriteria).length === 0; + return Object.values(passwordCriteria).every((value) => value === true); } - useDebounce(() => { - if (password.length === 0) { - setPasswordCriteria({}); - return; - } - - // Check that the password is strong enough with the API - fetch("/api/auth/check-password/", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), - }) - .then((res) => { - if (res.ok) { - res.json().then((data) => { - if (data.is_strong) { - setPasswordCriteria({}); - return; - } else { - setPasswordCriteria(data.criteria || {}); - } - }); - } else { - console.error("Fetch error:", res.status); - } - }) - .catch((err) => { - console.error("Fetch error:", err); - }); - }, [password]); - useEffect(() => { - if (password.length === 0) { - setPasswordCriteria({}); - return; - } + const { criteria } = PasswordValidation(password); + setPasswordCriteria(criteria); }, [password]); const stopRefresh = (e: React.FormEvent) => { @@ -127,10 +95,16 @@ export default function Page() { placeholder="Password" value={password} onChange={setPassword} + onFocus={() => setShowPasswordCriteria(true)} + onBlur={() => { + if (!password || passwordIsStrong()) { + setShowPasswordCriteria(false); + } + }} /> {/* Password Errors */} - {!passwordIsStrong() && ( + {showPasswordCriteria && (
diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx index a9918cc3..04998e5c 100644 --- a/src/app/(auth)/reset-password/page.tsx +++ b/src/app/(auth)/reset-password/page.tsx @@ -5,60 +5,28 @@ import React, { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import PasswordCriteria from "@/features/auth/components/password-criteria"; +import PasswordValidation from "@/features/auth/components/password-validation"; import TextInputField from "@/features/auth/components/text-input-field"; import ActionButton from "@/features/button/components/action"; -import { useDebounce } from "@/lib/hooks/use-debounce"; import formatApiError from "@/lib/utils/api/format-api-error"; export default function Page() { const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordCriteria, setPasswordCriteria] = useState({}); + const [showPasswordCriteria, setShowPasswordCriteria] = useState(false); const router = useRouter(); const searchParams = useSearchParams(); const pwdResetToken = searchParams.get("token"); function passwordIsStrong() { - return Object.keys(passwordCriteria).length === 0; + return Object.values(passwordCriteria).every((value) => value === true); } - useDebounce(() => { - if (newPassword.length === 0) { - setPasswordCriteria({}); - return; - } - - // Check that the password is strong enough with the API - fetch("/api/auth/check-password/", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password: newPassword }), - }) - .then((res) => { - if (res.ok) { - res.json().then((data) => { - if (data.is_strong) { - setPasswordCriteria({}); - return; - } else { - setPasswordCriteria(data.criteria || {}); - } - }); - } else { - console.error("Fetch error:", res.status); - } - }) - .catch((err) => { - console.error("Fetch error:", err); - }); - }, [newPassword]); - useEffect(() => { - if (newPassword.length === 0) { - setPasswordCriteria({}); - return; - } + const { criteria } = PasswordValidation(newPassword); + setPasswordCriteria(criteria); }, [newPassword]); const stopRefresh = (e: React.FormEvent) => { @@ -121,9 +89,16 @@ export default function Page() { placeholder="New Password" value={newPassword} onChange={setNewPassword} + onFocus={() => setShowPasswordCriteria(true)} + onBlur={() => { + if (!newPassword || passwordIsStrong()) { + setShowPasswordCriteria(false); + } + }} /> - {!passwordIsStrong() && ( + {/* Password Errors */} + {showPasswordCriteria && (
diff --git a/src/features/auth/components/password-criteria.tsx b/src/features/auth/components/password-criteria.tsx index 0f5ab3a3..ba5635d5 100644 --- a/src/features/auth/components/password-criteria.tsx +++ b/src/features/auth/components/password-criteria.tsx @@ -7,21 +7,32 @@ type PasswordCriteriaProps = { }; export default function PasswordCriteria(props: PasswordCriteriaProps) { + const allCriteriaMet = Object.values(props.criteria).every((value) => value); + return (
- Your password must: - {Object.entries(props.criteria).map(([key, value], index) => ( -
- {value ? : } - {key} + {allCriteriaMet ? ( +
+ + Password is strong!
- ))} + ) : ( + <> + Your password must: + {Object.entries(props.criteria).map(([key, value], index) => ( +
+ {value ? : } + {key} +
+ ))} + + )}
); } diff --git a/src/features/auth/components/password-validation.ts b/src/features/auth/components/password-validation.ts new file mode 100644 index 00000000..6031b0fd --- /dev/null +++ b/src/features/auth/components/password-validation.ts @@ -0,0 +1,33 @@ +const MIN_LENGTH = 8; +const SPECIAL_CHARACTERS = `!"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~`; + +export const PASSWORD_CRITERIA = { + LENGTH: `be at least ${MIN_LENGTH} characters long`, + LOWER: "contain at least one lowercase letter", + UPPER: "contain at least one uppercase letter", + DIGIT: "contain at least one digit", + SPECIAL: "contain at least one special character", +} as const; + +export type PasswordCriteriaResult = { + [key: string]: boolean; +}; + +export default function PasswordValidation(password: string): { + isStrong: boolean; + criteria: PasswordCriteriaResult; +} { + const criteria: PasswordCriteriaResult = { + [PASSWORD_CRITERIA.LENGTH]: password.length >= MIN_LENGTH, + [PASSWORD_CRITERIA.LOWER]: /[a-z]/.test(password), + [PASSWORD_CRITERIA.UPPER]: /[A-Z]/.test(password), + [PASSWORD_CRITERIA.DIGIT]: /\d/.test(password), + [PASSWORD_CRITERIA.SPECIAL]: new RegExp( + `[${SPECIAL_CHARACTERS.replace(/[\\^\-\]]/g, "\\$&")}]`, + ).test(password), + }; + + const isStrong = Object.values(criteria).every((passed) => passed); + + return { isStrong, criteria }; +} diff --git a/src/features/auth/components/text-input-field.tsx b/src/features/auth/components/text-input-field.tsx index f888d5e5..caca388d 100644 --- a/src/features/auth/components/text-input-field.tsx +++ b/src/features/auth/components/text-input-field.tsx @@ -9,10 +9,12 @@ type TextInputFieldProps = { placeholder: string; value: string; onChange: (value: string) => void; + onFocus?: () => void; + onBlur?: () => void; }; export default function TextInputField(props: TextInputFieldProps) { - const { type, placeholder, value, onChange } = props; + const { type, placeholder, value, onChange, onFocus, onBlur } = props; const [showPassword, setShowPassword] = useState(false); return ( @@ -22,6 +24,8 @@ export default function TextInputField(props: TextInputFieldProps) { placeholder={placeholder} value={value} onChange={(e) => onChange(e.target.value)} + onFocus={onFocus} + onBlur={onBlur} className={ "w-full rounded-full border px-4 py-2 focus:outline-none focus:ring-2" + (type === "password" ? " pr-10" : "")