feat: Add logo and remove elements in login page

This commit is contained in:
Tola Leng
2025-06-11 20:23:45 +08:00
parent 895da83999
commit 6d63d9f485
2 changed files with 77 additions and 293 deletions
+1
View File
@@ -51,6 +51,7 @@ if (typeof window !== 'undefined') {
localStorage.removeItem('pocketbase_auth'); localStorage.removeItem('pocketbase_auth');
} }
} }
// Subscribe to authStore changes to persist authentication // Subscribe to authStore changes to persist authentication
pb.authStore.onChange(() => { pb.authStore.onChange(() => {
if (pb.authStore.isValid) { if (pb.authStore.isValid) {
+68 -285
View File
@@ -1,10 +1,11 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { authService } from '@/services/authService'; import { authService } from '@/services/authService';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/components/ui/use-toast';
import { Eye, EyeOff, Mail, LogIn, Settings, AlertCircle } from "lucide-react"; import { Eye, EyeOff, Mail, LogIn, Settings } from "lucide-react";
import { useLanguage } from '@/contexts/LanguageContext'; import { useLanguage } from '@/contexts/LanguageContext';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { API_ENDPOINTS, getCurrentEndpoint, setApiEndpoint } from '@/lib/pocketbase'; import { API_ENDPOINTS, getCurrentEndpoint, setApiEndpoint } from '@/lib/pocketbase';
@@ -12,33 +13,18 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label'; 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 Login = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [currentEndpoint, setCurrentEndpoint] = useState(getCurrentEndpoint()); const [currentEndpoint, setCurrentEndpoint] = useState(getCurrentEndpoint());
const [errors, setErrors] = useState<{ email?: string; password?: string; general?: string }>({});
const [loginAttempts, setLoginAttempts] = useState<LoginAttempts>({ count: 0, lastAttempt: 0 });
const [isLocked, setIsLocked] = useState(false);
const [lockoutTimeRemaining, setLockoutTimeRemaining] = useState(0);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useLanguage(); const { t } = useLanguage();
const { theme } = useTheme(); const { theme } = useTheme();
const [isMobile, setIsMobile] = useState(false);
// Check if we're in development mode // Add responsiveness check
const isDevelopment = import.meta.env.DEV; const [isMobile, setIsMobile] = useState(false);
useEffect(() => { useEffect(() => {
const checkScreenSize = () => { const checkScreenSize = () => {
@@ -47,148 +33,31 @@ const Login = () => {
checkScreenSize(); checkScreenSize();
window.addEventListener('resize', checkScreenSize); window.addEventListener('resize', checkScreenSize);
return () => { return () => {
window.removeEventListener('resize', checkScreenSize); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isLocked) {
toast({
variant: "destructive",
title: t("accountLocked"),
description: t("tooManyAttempts"),
});
return;
}
if (!validateForm()) {
return;
}
setLoading(true); setLoading(true);
setErrors({});
try { try {
await authService.login({ email: email.toLowerCase().trim(), password }); await authService.login({ email, password });
updateLoginAttempts(false);
toast({ toast({
title: t("loginSuccessful"), title: t("loginSuccessful"),
description: t("loginSuccessMessage"), description: t("loginSuccessMessage"),
}); });
navigate('/dashboard'); navigate('/dashboard');
} catch (error) { } catch (error) {
updateLoginAttempts(true); console.error("Login error details:", error);
// 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({ toast({
variant: "destructive", variant: "destructive",
title: t("loginFailed"), title: t("loginFailed"),
description: errorMessage, description: error instanceof Error
? error.message
: `${t("authenticationFailed")}. Server: ${currentEndpoint}`,
}); });
} finally { } finally {
setLoading(false); setLoading(false);
@@ -204,175 +73,103 @@ const Login = () => {
setShowPassword(!showPassword); 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 ( return (
<div className="flex items-center justify-center min-h-screen bg-background p-4"> <div className="flex items-center justify-center min-h-screen bg-background p-4">
<div className="w-full max-w-md p-4 sm:p-8 space-y-6 rounded-xl bg-card shadow-xl border border-border/20"> <div className="w-full max-w-md p-4 sm:p-8 space-y-6 rounded-xl bg-card shadow-xl border border-border/20">
<div className="text-center relative"> <div className="text-center relative">
{/* API Endpoint Settings - Only show in development */} {/* Commented out API Endpoint Settings button
{/*isDevelopment && ( <div className="absolute right-0 top-0">
<div className="absolute right-0 top-0"> <Popover>
<Popover> <PopoverTrigger asChild>
<PopoverTrigger asChild> <Button variant="outline" size="icon" aria-label="API Settings">
<Button variant="ghost" size="icon" className="h-8 w-8"> <Settings className="h-4 w-4" />
<Settings className="h-4 w-4" /> </Button>
</Button> </PopoverTrigger>
</PopoverTrigger> <PopoverContent className="w-[260px] sm:w-80">
<PopoverContent className="w-[260px] sm:w-80"> <div className="space-y-4">
<div className="space-y-4"> <h4 className="font-medium">API Endpoint Settings</h4>
<h4 className="font-medium">API Endpoint Settings</h4> <RadioGroup
<RadioGroup value={currentEndpoint}
value={currentEndpoint} onValueChange={handleEndpointChange}
onValueChange={handleEndpointChange} className="gap-2"
className="gap-2" >
> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2"> <RadioGroupItem value={API_ENDPOINTS.LOCAL} id="local" />
<RadioGroupItem value={API_ENDPOINTS.LOCAL} id="local" /> <Label htmlFor="local" className="truncate text-sm">Local: {API_ENDPOINTS.LOCAL}</Label>
<Label htmlFor="local" className="truncate text-sm">
Local: {API_ENDPOINTS.LOCAL}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={API_ENDPOINTS.REMOTE} id="remote" />
<Label htmlFor="remote" className="truncate text-sm">
Remote: {API_ENDPOINTS.REMOTE}
</Label>
</div>
</RadioGroup>
<div className="text-xs text-muted-foreground truncate">
Current: {currentEndpoint}
</div> </div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={API_ENDPOINTS.REMOTE} id="remote" />
<Label htmlFor="remote" className="truncate text-sm">Remote: {API_ENDPOINTS.REMOTE}</Label>
</div>
</RadioGroup>
<div className="text-xs text-muted-foreground truncate">
Current endpoint: {currentEndpoint}
</div> </div>
</PopoverContent> </div>
</Popover> </PopoverContent>
</div> </Popover>
)*/} </div>
*/}
{/* Logo */} {/* Logo */}
<img <div className="mb-4">
src="/checkcle_logo.svg" <img
alt="Checkcle Logo" src="/checkcle_logo.svg"
className="h-16 sm:h-20 mx-auto mb-4" alt="Checkcle Logo"
onError={(e) => { className="mx-auto h-20 w-auto"
(e.target as HTMLImageElement).style.display = 'none'; />
}} </div>
/>
<h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground"> <h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground">{t("signInToYourAccount")}</h1>
{t("signInToYourAccount")} {/* Removed "Don't have an account? Create one" text */}
</h1>
{isLocked && (
<div className="mt-4 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" />
<span>
{t("accountLocked")} - {formatLockoutTime(lockoutTimeRemaining)}
</span>
</div>
</div>
)}
</div> </div>
<form onSubmit={handleSubmit} className="mt-4 sm:mt-6 space-y-4 sm:space-y-6"> {/* Removed Google Sign in button section */}
{/* General Error */}
{errors.general && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" />
<span>{errors.general}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
<div className="space-y-1 sm:space-y-2"> <div className="space-y-1 sm:space-y-2">
<label htmlFor="email" className="text-xs sm:text-sm font-medium text-foreground"> <label className="text-xs sm:text-sm font-medium text-foreground" htmlFor="email">{t("email")}</label>
{t("email")}
</label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Mail className="h-4 w-4 text-muted-foreground" /> <Mail className="h-4 w-4 text-muted-foreground" />
</div> </div>
<Input <Input
id="email" id="email"
name="email"
placeholder="your.email@provider.com" placeholder="your.email@provider.com"
type="email" type="email"
value={email} value={email}
onChange={(e) => { onChange={(e) => setEmail(e.target.value)}
setEmail(e.target.value);
if (errors.email) {
setErrors(prev => ({ ...prev, email: undefined }));
}
}}
required required
autoComplete="email" className="pl-10 text-sm sm:text-base h-9 sm:h-10"
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}
/> />
</div> </div>
{errors.email && (
<p id="email-error" className="text-xs text-destructive">
{errors.email}
</p>
)}
</div> </div>
<div className="space-y-1 sm:space-y-2"> <div className="space-y-1 sm:space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label htmlFor="password" className="text-xs sm:text-sm font-medium text-foreground"> <label className="text-xs sm:text-sm font-medium text-foreground" htmlFor="password">{t("password")}</label>
{t("password")} <a href="#" className="text-xs sm:text-sm text-emerald-500 hover:text-emerald-400">{t("forgot")}</a>
</label>
<a
href="#"
className="text-xs sm:text-sm text-primary hover:text-primary/80 transition-colors"
onClick={(e) => e.preventDefault()}
>
{t("forgot")}
</a>
</div> </div>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground">
fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" className="text-muted-foreground">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path> <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg> </svg>
</div> </div>
<Input <Input
id="password" id="password"
name="password"
placeholder="••••••••••••" placeholder="••••••••••••"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => { onChange={(e) => setPassword(e.target.value)}
setPassword(e.target.value);
if (errors.password) {
setErrors(prev => ({ ...prev, password: undefined }));
}
}}
required required
autoComplete="current-password" className="pl-10 text-sm sm:text-base h-9 sm:h-10"
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}
/> />
<button <button
type="button" type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3" className="absolute inset-y-0 right-0 flex items-center pr-3"
onClick={togglePasswordVisibility} onClick={togglePasswordVisibility}
disabled={loading || isLocked}
aria-label={showPassword ? "Hide password" : "Show password"} aria-label={showPassword ? "Hide password" : "Show password"}
> >
{showPassword ? ( {showPassword ? (
@@ -382,33 +179,19 @@ const Login = () => {
)} )}
</button> </button>
</div> </div>
{errors.password && (
<p id="password-error" className="text-xs text-destructive">
{errors.password}
</p>
)}
</div> </div>
<Button <Button
type="submit" type="submit"
className="w-full py-2 sm:py-6 font-medium text-sm sm:text-base" className="w-full py-2 sm:py-6 bg-emerald-500 hover:bg-emerald-600 text-white font-medium text-sm sm:text-base"
disabled={loading || isLocked} disabled={loading}
> >
{loading ? ( {loading ? t("signingIn") : t("signIn")}
<> {!loading && <LogIn className="ml-2 h-4 w-4" />}
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent mr-2" />
{t("signingIn")}
</>
) : (
<>
{t("signIn")}
<LogIn className="ml-2 h-4 w-4" />
</>
)}
</Button> </Button>
<p className="text-xs sm:text-xs text-center text-muted-foreground"> <p className="text-xxs sm:text-xs text-center text-muted-foreground">
{t("bySigningIn")} <a href="#" className="text-primary hover:text-primary/80">{t("termsAndConditions")}</a> {t("and")} <a href="#" className="text-primary hover:text-primary/80">{t("privacyPolicy")}</a>. {t("bySigningIn")} <a href="#" className="text-emerald-500">{t("termsAndConditions")}</a> {t("and")} <a href="#" className="text-emerald-500">{t("privacyPolicy")}</a>.
</p> </p>
</form> </form>
</div> </div>