From 895da83999c763f71cdeb5dec40c3e0e8e9f0a7b Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Sun, 8 Jun 2025 22:44:17 +0800 Subject: [PATCH 1/4] Fixed Authentication Persistence --- application/src/lib/pocketbase.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/application/src/lib/pocketbase.ts b/application/src/lib/pocketbase.ts index 4ad1dfb..ce192e9 100644 --- a/application/src/lib/pocketbase.ts +++ b/application/src/lib/pocketbase.ts @@ -51,7 +51,6 @@ if (typeof window !== 'undefined') { localStorage.removeItem('pocketbase_auth'); } } - // Subscribe to authStore changes to persist authentication pb.authStore.onChange(() => { if (pb.authStore.isValid) { From 6d63d9f48516a3ce25b6a11c98b8fd69bc1aad2d Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Wed, 11 Jun 2025 20:23:45 +0800 Subject: [PATCH 2/4] feat: Add logo and remove elements in login page --- application/src/lib/pocketbase.ts | 1 + application/src/pages/Login.tsx | 367 ++++++------------------------ 2 files changed, 76 insertions(+), 292 deletions(-) diff --git a/application/src/lib/pocketbase.ts b/application/src/lib/pocketbase.ts index ce192e9..4ad1dfb 100644 --- a/application/src/lib/pocketbase.ts +++ b/application/src/lib/pocketbase.ts @@ -51,6 +51,7 @@ if (typeof window !== 'undefined') { localStorage.removeItem('pocketbase_auth'); } } + // Subscribe to authStore changes to persist authentication pb.authStore.onChange(() => { if (pb.authStore.isValid) { diff --git a/application/src/pages/Login.tsx b/application/src/pages/Login.tsx index d4a7797..efaa951 100644 --- a/application/src/pages/Login.tsx +++ b/application/src/pages/Login.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect, useCallback } from 'react'; + +import { useState, useEffect } 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 '@/hooks/use-toast'; -import { Eye, EyeOff, Mail, LogIn, Settings, AlertCircle } from "lucide-react"; +import { toast } from '@/components/ui/use-toast'; +import { Eye, EyeOff, Mail, LogIn, Settings } from "lucide-react"; import { useLanguage } from '@/contexts/LanguageContext'; import { useTheme } from '@/contexts/ThemeContext'; import { API_ENDPOINTS, getCurrentEndpoint, setApiEndpoint } from '@/lib/pocketbase'; @@ -12,183 +13,51 @@ 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(); - const [isMobile, setIsMobile] = useState(false); - - // Check if we're in development mode - const isDevelopment = import.meta.env.DEV; + // Add responsiveness check + const [isMobile, setIsMobile] = useState(false); + 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: email.toLowerCase().trim(), password }); - - updateLoginAttempts(false); - + await authService.login({ email, password }); toast({ title: t("loginSuccessful"), description: t("loginSuccessMessage"), }); - navigate('/dashboard'); } catch (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 }); - + console.error("Login error details:", error); toast({ variant: "destructive", title: t("loginFailed"), - description: errorMessage, + description: error instanceof Error + ? error.message + : `${t("authenticationFailed")}. Server: ${currentEndpoint}`, }); } finally { setLoading(false); @@ -204,175 +73,103 @@ 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 (
- {/* API Endpoint Settings - Only show in development */} - {/*isDevelopment && ( -
- - - - - -
-

API Endpoint Settings

- -
- - -
-
- - -
-
-
- Current: {currentEndpoint} + {/* Commented out API Endpoint Settings button +
+ + + + + +
+

API Endpoint Settings

+ +
+ + +
+
+ +
+
+
+ Current endpoint: {currentEndpoint}
- - -
- )*/} - +
+ + +
+ */} + {/* Logo */} - Checkcle Logo { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - -

- {t("signInToYourAccount")} -

+
+ Checkcle Logo +
- {isLocked && ( -
-
- - - {t("accountLocked")} - {formatLockoutTime(lockoutTimeRemaining)} - -
-
- )} +

{t("signInToYourAccount")}

+ {/* Removed "Don't have an account? Create one" text */}
-
- {/* General Error */} - {errors.general && ( -
-
- - {errors.general} -
-
- )} + {/* Removed Google Sign in button section */} +
- +
{ - setEmail(e.target.value); - if (errors.email) { - setErrors(prev => ({ ...prev, email: undefined })); - } - }} + onChange={(e) => setEmail(e.target.value)} required - 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} + className="pl-10 text-sm sm:text-base h-9 sm:h-10" />
- {errors.email && ( -

- {errors.email} -

- )}
- +
- - e.preventDefault()} - > - {t("forgot")} - + + {t("forgot")}
- +
{ - setPassword(e.target.value); - if (errors.password) { - setErrors(prev => ({ ...prev, password: undefined })); - } - }} + onChange={(e) => setPassword(e.target.value)} required - 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} + className="pl-10 text-sm sm:text-base h-9 sm:h-10" />
- {errors.password && ( -

- {errors.password} -

- )}
- -

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

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

@@ -416,4 +199,4 @@ const Login = () => { ); }; -export default Login; +export default Login; \ No newline at end of file From 8dc3bd6a0dc94524f27035be43323693260879ba Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 12 Jun 2025 18:08:36 +0800 Subject: [PATCH 3/4] Implement password reset functionality. Added password reset request and confirmation to the login page, using the provided API endpoints. --- application/src/pages/Login.tsx | 64 +++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/application/src/pages/Login.tsx b/application/src/pages/Login.tsx index efaa951..37418d1 100644 --- a/application/src/pages/Login.tsx +++ b/application/src/pages/Login.tsx @@ -5,13 +5,15 @@ 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 { Alert, AlertDescription } from '@/components/ui/alert'; +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'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; +import { ForgotPasswordDialog } from '@/components/auth/ForgotPasswordDialog'; const Login = () => { const [email, setEmail] = useState(''); @@ -19,6 +21,8 @@ const Login = () => { const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); const [currentEndpoint, setCurrentEndpoint] = useState(getCurrentEndpoint()); + const [showForgotPassword, setShowForgotPassword] = useState(false); + const [loginError, setLoginError] = useState(''); const navigate = useNavigate(); const { t } = useLanguage(); const { theme } = useTheme(); @@ -42,6 +46,7 @@ const Login = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); + setLoginError(''); // Clear previous errors try { await authService.login({ email, password }); @@ -52,11 +57,31 @@ const Login = () => { navigate('/dashboard'); } catch (error) { console.error("Login error details:", error); + + // Set specific error message based on the error + if (error instanceof Error) { + if (error.message.includes('Failed to authenticate') || + error.message.includes('Authentication failed') || + error.message.includes('Invalid credentials') || + error.message.includes('invalid email or password')) { + setLoginError(t("invalidCredentials")); + } else { + setLoginError(error.message); + } + } else { + setLoginError(`${t("authenticationFailed")}. Server: ${currentEndpoint}`); + } + toast({ variant: "destructive", title: t("loginFailed"), description: error instanceof Error - ? error.message + ? (error.message.includes('Failed to authenticate') || + error.message.includes('Authentication failed') || + error.message.includes('Invalid credentials') || + error.message.includes('invalid email or password')) + ? t("invalidCredentials") + : error.message : `${t("authenticationFailed")}. Server: ${currentEndpoint}`, }); } finally { @@ -115,8 +140,8 @@ const Login = () => {
Checkcle Logo
@@ -127,6 +152,14 @@ const Login = () => { {/* Removed Google Sign in button section */}
+ {/* Error Alert */} + {loginError && ( + + + {loginError} + + )} +
@@ -138,7 +171,10 @@ const Login = () => { placeholder="your.email@provider.com" type="email" value={email} - onChange={(e) => setEmail(e.target.value)} + onChange={(e) => { + setEmail(e.target.value); + setLoginError(''); // Clear error when user starts typing + }} required className="pl-10 text-sm sm:text-base h-9 sm:h-10" /> @@ -148,7 +184,13 @@ const Login = () => {
- {t("forgot")} +
@@ -162,7 +204,10 @@ const Login = () => { placeholder="••••••••••••" type={showPassword ? 'text' : 'password'} value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value); + setLoginError(''); // Clear error when user starts typing + }} required className="pl-10 text-sm sm:text-base h-9 sm:h-10" /> @@ -195,6 +240,11 @@ const Login = () => {

+ +
); }; From e1d466a6387f8af5e5e41e24b66de300e7ecb147 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 12 Jun 2025 18:08:51 +0800 Subject: [PATCH 4/4] feat: Implement password reset API calls Updated the `ForgotPasswordDialog` component to use the provided API paths for requesting and confirming password resets. --- .../components/auth/ForgotPasswordDialog.tsx | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 application/src/components/auth/ForgotPasswordDialog.tsx diff --git a/application/src/components/auth/ForgotPasswordDialog.tsx b/application/src/components/auth/ForgotPasswordDialog.tsx new file mode 100644 index 0000000..1afad40 --- /dev/null +++ b/application/src/components/auth/ForgotPasswordDialog.tsx @@ -0,0 +1,259 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { toast } from '@/components/ui/use-toast'; +import { Mail, ArrowLeft } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { getCurrentEndpoint } from '@/lib/pocketbase'; + +interface ForgotPasswordDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ForgotPasswordDialog({ open, onOpenChange }: ForgotPasswordDialogProps) { + const [step, setStep] = useState<'request' | 'confirm'>('request'); + const [email, setEmail] = useState(''); + const [token, setToken] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [loading, setLoading] = useState(false); + const { t } = useLanguage(); + + const handleRequestReset = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + const apiUrl = getCurrentEndpoint(); + const response = await fetch(`${apiUrl}/api/collections/_superusers/request-password-reset`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Failed to send reset email'); + } + + toast({ + title: "Reset Email Sent", + description: "Please check your email for password reset instructions.", + }); + setStep('confirm'); + } catch (error) { + console.error('Password reset request error:', error); + toast({ + variant: "destructive", + title: "Reset Failed", + description: error instanceof Error ? error.message : "Failed to send reset email. Please try again.", + }); + } finally { + setLoading(false); + } + }; + + const handleConfirmReset = async (e: React.FormEvent) => { + e.preventDefault(); + + if (password !== passwordConfirm) { + toast({ + variant: "destructive", + title: "Password Mismatch", + description: "Passwords do not match. Please try again.", + }); + return; + } + + if (password.length < 6) { + toast({ + variant: "destructive", + title: "Password Too Short", + description: "Password must be at least 6 characters long.", + }); + return; + } + + setLoading(true); + + try { + const apiUrl = getCurrentEndpoint(); + const response = await fetch(`${apiUrl}/api/collections/_superusers/confirm-password-reset`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token, + password, + passwordConfirm + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Failed to reset password'); + } + + toast({ + title: "Password Reset Successful", + description: "Your password has been reset successfully. You can now log in with your new password.", + }); + onOpenChange(false); + // Reset form state + setStep('request'); + setEmail(''); + setToken(''); + setPassword(''); + setPasswordConfirm(''); + } catch (error) { + console.error('Password reset confirmation error:', error); + toast({ + variant: "destructive", + title: "Reset Failed", + description: error instanceof Error ? error.message : "Failed to reset password. Please try again.", + }); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + // Reset form state when closing + setStep('request'); + setEmail(''); + setToken(''); + setPassword(''); + setPasswordConfirm(''); + }; + + return ( + + + + + {step === 'request' ? 'Reset Password' : 'Confirm Password Reset'} + + + {step === 'request' + ? 'Enter your email address and we\'ll send you a reset link.' + : 'Enter the reset token from your email and your new password.' + } + + + + {step === 'request' ? ( +
+
+ +
+
+ +
+ setEmail(e.target.value)} + required + className="pl-10" + /> +
+
+ +
+ + +
+
+ ) : ( +
+
+ + setToken(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={6} + /> +
+ +
+ + setPasswordConfirm(e.target.value)} + required + minLength={6} + /> +
+ +
+ + +
+
+ )} +
+
+ ); +} \ No newline at end of file