diff --git a/client/package-lock.json b/client/package-lock.json index 43f9372..3caa722 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -32,7 +32,8 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "zod": "^4.3.5" }, "devDependencies": { "@eslint/js": "^9.33.0", @@ -6659,6 +6660,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/client/package.json b/client/package.json index e90ea5b..561e523 100644 --- a/client/package.json +++ b/client/package.json @@ -35,7 +35,8 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "zod": "^4.3.5" }, "devDependencies": { "@eslint/js": "^9.33.0", diff --git a/client/src/components/auth/AuthForm.tsx b/client/src/components/auth/AuthForm.tsx index 9e91cfa..3c2113d 100644 --- a/client/src/components/auth/AuthForm.tsx +++ b/client/src/components/auth/AuthForm.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; import { User, Lock, UserPlus, LogIn, Mail, Shield } from "lucide-react"; import { login, signup, sendOTP } from "../../utils/api"; - +import PasswordInput from "../ui/PasswordInput"; interface AuthFormProps { onAuthChange: () => void; } @@ -210,22 +210,13 @@ const AuthForm: React.FC = ({ onAuthChange }) => { -
- - setPassword(e.target.value)} - className="alien-input w-full pl-10" - placeholder="Enter your password" - required - /> -
+ setPassword(e.target.value)} + className="alien-input" // This keeps your project's green/black theme + placeholder="Enter your password" + /> - {error && (
{error} diff --git a/client/src/components/ui/PasswordInput.tsx b/client/src/components/ui/PasswordInput.tsx new file mode 100644 index 0000000..14aacd1 --- /dev/null +++ b/client/src/components/ui/PasswordInput.tsx @@ -0,0 +1,164 @@ +import React, { useState, useEffect } from 'react'; +import { Eye, EyeOff, KeyRound } from 'lucide-react'; + +import { passwordSchema } from '../../schemas/auth.schema'; + +interface PasswordInputProps { + value: string; + onChange: (e: React.ChangeEvent) => void; + placeholder?: string; + name?: string; + id?: string; + className?: string; +} + + +const PasswordInput: React.FC = ({ + value, + onChange, + placeholder = "Enter password", + name, + id, + className = "" +}) => { + const [showPassword, setShowPassword] = useState(false); + const [strength, setStrength] = useState(0); + const [errors, setErrors] = useState([]); // New state for Zod errors + + + const calculateVisualStrength = (password: string) => { + let score = 0; + if (!password) return 0; + if (password.length > 8) score++; + if (/[A-Z]/.test(password)) score++; + if (/[0-9]/.test(password)) score++; + if (/[^A-Za-z0-9]/.test(password)) score++; + return score; + }; + + + useEffect(() => { + // Run the text against the shared schema + const result = passwordSchema.safeParse(value); + + if (!result.success) { + // If invalid, extract the specific error messages + const errorMessages = result.error.issues.map((issue) => issue.message); + setErrors(errorMessages); + } else { + // If valid, clear errors + setErrors([]); + } + + // Update the visual strength meter + setStrength(calculateVisualStrength(value)); + }, [value]); + + const handleInputChange = (e: React.ChangeEvent) => { + onChange(e); + // Validation is handled by the useEffect above + }; + + const generatePassword = () => { + // 1. Define sets + const lower = "abcdefghijklmnopqrstuvwxyz"; + const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const nums = "0123456789"; + const special = "!@#$%^&*"; + const allChars = lower + upper + nums + special; + + let generated = ""; + + // 2. GUARANTEE one of each required type + generated += lower[Math.floor(Math.random() * lower.length)]; + generated += upper[Math.floor(Math.random() * upper.length)]; + generated += nums[Math.floor(Math.random() * nums.length)]; + generated += special[Math.floor(Math.random() * special.length)]; + + // 3. Fill the rest (aiming for length 12-14) + for (let i = generated.length; i < 14; i++) { + generated += allChars[Math.floor(Math.random() * allChars.length)]; + } + + // 4. Shuffle the result so the pattern isn't predictable + generated = generated.split('').sort(() => 0.5 - Math.random()).join(''); + + const event = { + target: { name, value: generated } + } as React.ChangeEvent; + + handleInputChange(event); + }; + + const getStrengthColor = (score: number) => { + if (score < 2) return 'bg-red-500'; + if (score < 3) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + const getStrengthLabel = (score: number) => { + const labels = ['Weak', 'Fair', 'Good', 'Strong', 'Excellent']; + return labels[score] || 'Weak'; + }; + + return ( +
+
+ 0 && value.length > 0 ? 'border-red-500 focus:ring-red-500' : 'border-gray-300' + } ${className}`} + /> + + +
+ +
+ {value ? ( +
+ {/* Visual Bar */} +
+
+ + {getStrengthLabel(strength)} + +
+ + {/* Zod Error Messages Displayed Here */} + {errors.length > 0 && ( +
    + {errors.slice(0, 3).map((err, idx) => ( // Limit to showing first 3 errors to save space +
  • {err}
  • + ))} +
+ )} +
+ ) :
} + + +
+
+ ); +}; + +export default PasswordInput; \ No newline at end of file diff --git a/client/src/schemas/auth.schema.ts b/client/src/schemas/auth.schema.ts new file mode 100644 index 0000000..30dd701 --- /dev/null +++ b/client/src/schemas/auth.schema.ts @@ -0,0 +1,61 @@ +// src/schemas/auth.schema.ts +import { z } from 'zod'; + + +export const passwordSchema = z + .string() + .min(8, 'Password must be at least 8 characters long') + .regex(/[a-z]/, 'Password must include at least one lowercase letter') + .regex(/[A-Z]/, 'Password must include at least one uppercase letter') + .regex(/[0-9]/, 'Password must include at least one number') + .regex( + /[^A-Za-z0-9]/, + 'Password must include at least one special character (! @#$%^&*... )' + ) + .refine( + (password: string) => { + // Block common passwords + const commonPasswords = [ + 'password', 'password123', '123456', '123456789', '12345678', + 'qwerty', 'abc123', 'monkey', '1234567', 'letmein', + 'trustno1', 'dragon', 'baseball', 'iloveyou', 'master', + 'sunshine', 'ashley', 'bailey', 'shadow', 'superman', + 'qazwsx', 'welcome', 'admin', 'login', 'passw0rd' + ]; + return !commonPasswords.includes(password.toLowerCase()); + }, + { message: 'This password is too common. Please choose a more secure password' } + ) + .refine( + (password: string) => { + // Block sequential characters (abc, 123) + const sequential = /(abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz|012|123|234|345|456|567|678|789)/i; + return !sequential.test(password); + }, + { message: 'Password should not contain sequential characters (e.g., "abc", "123")' } + ) + .refine( + (password: string) => { + // Block repeated characters (aaa, 111) + return !/(.)\1{2,}/.test(password); + }, + { message: 'Password should not contain repeated characters (e.g., "aaa", "111")' } + ); + +export type PasswordType = z.infer; + +// Signup Schema (Optional, if you need the full object validation) +export const signupSchema = z.object({ + username: z + .string() + .min(3, 'Username must be at least 3 characters') + .max(30, 'Username must not exceed 30 characters') + .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'), + email: z + .string() + .email('Please enter a valid email address'), + password: passwordSchema, + otp: z.string().optional() +}); + +export type SignupType = z.infer; \ No newline at end of file