diff --git a/application/src/pages/Login.tsx b/application/src/pages/Login.tsx index 089773b..d4a7797 100644 --- a/application/src/pages/Login.tsx +++ b/application/src/pages/Login.tsx @@ -1,11 +1,10 @@ - -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { authService } from '@/services/authService'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { toast } from '@/components/ui/use-toast'; -import { Eye, EyeOff, Mail, LogIn, Settings } from "lucide-react"; +import { toast } from '@/hooks/use-toast'; +import { Eye, EyeOff, Mail, LogIn, Settings, AlertCircle } from "lucide-react"; import { useLanguage } from '@/contexts/LanguageContext'; import { useTheme } from '@/contexts/ThemeContext'; import { API_ENDPOINTS, getCurrentEndpoint, setApiEndpoint } from '@/lib/pocketbase'; @@ -13,51 +12,183 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; +const MAX_LOGIN_ATTEMPTS = 5; +const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes + +interface LoginAttempts { + count: number; + lastAttempt: number; + lockedUntil?: number; +} + const Login = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); const [currentEndpoint, setCurrentEndpoint] = useState(getCurrentEndpoint()); + const [errors, setErrors] = useState<{ email?: string; password?: string; general?: string }>({}); + const [loginAttempts, setLoginAttempts] = useState({ count: 0, lastAttempt: 0 }); + const [isLocked, setIsLocked] = useState(false); + const [lockoutTimeRemaining, setLockoutTimeRemaining] = useState(0); + const navigate = useNavigate(); const { t } = useLanguage(); const { theme } = useTheme(); - - // Add responsiveness check const [isMobile, setIsMobile] = useState(false); - + + // Check if we're in development mode + const isDevelopment = import.meta.env.DEV; + useEffect(() => { const checkScreenSize = () => { setIsMobile(window.innerWidth < 640); }; - + checkScreenSize(); window.addEventListener('resize', checkScreenSize); - return () => { window.removeEventListener('resize', checkScreenSize); }; }, []); + // Load login attempts from localStorage + useEffect(() => { + const stored = localStorage.getItem('login_attempts'); + if (stored) { + try { + const attempts: LoginAttempts = JSON.parse(stored); + setLoginAttempts(attempts); + + if (attempts.lockedUntil && Date.now() < attempts.lockedUntil) { + setIsLocked(true); + setLockoutTimeRemaining(Math.ceil((attempts.lockedUntil - Date.now()) / 1000)); + } + } catch { + localStorage.removeItem('login_attempts'); + } + } + }, []); + + // Update lockout timer + useEffect(() => { + if (isLocked && lockoutTimeRemaining > 0) { + const timer = setInterval(() => { + setLockoutTimeRemaining(prev => { + if (prev <= 1) { + setIsLocked(false); + setLoginAttempts({ count: 0, lastAttempt: 0 }); + localStorage.removeItem('login_attempts'); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + } + }, [isLocked, lockoutTimeRemaining]); + + const validateEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const validatePassword = (password: string): boolean => { + return password.length >= 6; // Minimum 6 characters + }; + + const validateForm = (): boolean => { + const newErrors: { email?: string; password?: string } = {}; + + if (!email.trim()) { + newErrors.email = t("emailRequired"); + } else if (!validateEmail(email)) { + newErrors.email = t("emailInvalid"); + } + + if (!password) { + newErrors.password = t("passwordRequired"); + } else if (!validatePassword(password)) { + newErrors.password = t("passwordTooShort"); + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const updateLoginAttempts = useCallback((failed: boolean) => { + const now = Date.now(); + + if (failed) { + const newCount = loginAttempts.count + 1; + let newAttempts: LoginAttempts = { + count: newCount, + lastAttempt: now + }; + + if (newCount >= MAX_LOGIN_ATTEMPTS) { + newAttempts.lockedUntil = now + LOCKOUT_DURATION; + setIsLocked(true); + setLockoutTimeRemaining(LOCKOUT_DURATION / 1000); + } + + setLoginAttempts(newAttempts); + localStorage.setItem('login_attempts', JSON.stringify(newAttempts)); + } else { + // Reset on successful login + setLoginAttempts({ count: 0, lastAttempt: 0 }); + localStorage.removeItem('login_attempts'); + } + }, [loginAttempts.count]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + if (isLocked) { + toast({ + variant: "destructive", + title: t("accountLocked"), + description: t("tooManyAttempts"), + }); + return; + } + + if (!validateForm()) { + return; + } + setLoading(true); + setErrors({}); try { - await authService.login({ email, password }); + await authService.login({ email: email.toLowerCase().trim(), password }); + + updateLoginAttempts(false); + toast({ title: t("loginSuccessful"), description: t("loginSuccessMessage"), }); + navigate('/dashboard'); } catch (error) { - console.error("Login error details:", error); + updateLoginAttempts(true); + + // Generic error message for security + const remainingAttempts = MAX_LOGIN_ATTEMPTS - (loginAttempts.count + 1); + let errorMessage = t("invalidCredentials"); + + if (remainingAttempts > 0 && remainingAttempts <= 2) { + errorMessage += ` ${t("attemptsRemaining")}: ${remainingAttempts}`; + } + + setErrors({ general: errorMessage }); + toast({ variant: "destructive", title: t("loginFailed"), - description: error instanceof Error - ? error.message - : `${t("authenticationFailed")}. Server: ${currentEndpoint}`, + description: errorMessage, }); } finally { setLoading(false); @@ -73,96 +204,175 @@ const Login = () => { setShowPassword(!showPassword); }; + const formatLockoutTime = (seconds: number): string => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; + }; + return (
-
- - - {/* //this allow test api connection in login page -
-

API Endpoint Settings

- -
- - + {/* API Endpoint Settings - Only show in development */} + {/*isDevelopment && ( +
+ + + + + +
+

API Endpoint Settings

+ +
+ + +
+
+ + +
+
+
+ Current: {currentEndpoint}
-
- - -
- -
- Current endpoint: {currentEndpoint}
-
-
*/} -
-
-

{t("signInToYourAccount")}

- -
- -
-
-
-
+ +
-
- {t("orContinueWith")} + )*/} + + {/* Logo */} + Checkcle Logo { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + +

+ {t("signInToYourAccount")} +

+ + {isLocked && ( +
+
+ + + {t("accountLocked")} - {formatLockoutTime(lockoutTimeRemaining)} + +
-
+ )}
+ {/* General Error */} + {errors.general && ( +
+
+ + {errors.general} +
+
+ )} +
- +
setEmail(e.target.value)} + onChange={(e) => { + setEmail(e.target.value); + if (errors.email) { + setErrors(prev => ({ ...prev, email: undefined })); + } + }} required - className="pl-10 text-sm sm:text-base h-9 sm:h-10" + autoComplete="email" + disabled={loading || isLocked} + className={`pl-10 text-sm sm:text-base h-9 sm:h-10 ${ + errors.email ? 'border-destructive focus:border-destructive' : '' + }`} + aria-describedby={errors.email ? 'email-error' : undefined} />
+ {errors.email && ( +

+ {errors.email} +

+ )}
- +
- - {t("forgot")} + + e.preventDefault()} + > + {t("forgot")} +
- +
setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value); + if (errors.password) { + setErrors(prev => ({ ...prev, password: undefined })); + } + }} required - className="pl-10 text-sm sm:text-base h-9 sm:h-10" + autoComplete="current-password" + disabled={loading || isLocked} + className={`pl-10 pr-10 text-sm sm:text-base h-9 sm:h-10 ${ + errors.password ? 'border-destructive focus:border-destructive' : '' + }`} + aria-describedby={errors.password ? 'password-error' : undefined} />
+ {errors.password && ( +

+ {errors.password} +

+ )}
- -

- {t("bySigningIn")} {t("termsAndConditions")} {t("and")} {t("privacyPolicy")}. +

+ {t("bySigningIn")} {t("termsAndConditions")} {t("and")} {t("privacyPolicy")}.