Merge pull request #42 from operacle/develop

Implement password reset functionality.
This commit is contained in:
Tola Leng
2025-06-12 17:15:08 +07:00
committed by GitHub
2 changed files with 372 additions and 280 deletions
@@ -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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'request' ? 'Reset Password' : 'Confirm Password Reset'}
</DialogTitle>
<DialogDescription>
{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.'
}
</DialogDescription>
</DialogHeader>
{step === 'request' ? (
<form onSubmit={handleRequestReset} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="reset-email">
Email Address
</label>
<div className="relative">
<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" />
</div>
<Input
id="reset-email"
placeholder="your.email@provider.com"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10"
/>
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !email}
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
>
{loading ? 'Sending...' : 'Send Reset Email'}
</Button>
</div>
</form>
) : (
<form onSubmit={handleConfirmReset} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="reset-token">
Reset Token
</label>
<Input
id="reset-token"
placeholder="Enter token from email"
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="new-password">
New Password
</label>
<Input
id="new-password"
placeholder="••••••••••••"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="confirm-password">
Confirm Password
</label>
<Input
id="confirm-password"
placeholder="••••••••••••"
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required
minLength={6}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setStep('request')}
className="flex-1"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<Button
type="submit"
disabled={loading || !token || !password || !passwordConfirm}
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
>
{loading ? 'Resetting...' : 'Reset Password'}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
+113 -280
View File
@@ -1,9 +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 { Alert, AlertDescription } from '@/components/ui/alert';
import { Eye, EyeOff, Mail, LogIn, Settings, AlertCircle } from "lucide-react"; import { Eye, EyeOff, Mail, LogIn, Settings, AlertCircle } from "lucide-react";
import { useLanguage } from '@/contexts/LanguageContext'; import { useLanguage } from '@/contexts/LanguageContext';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
@@ -11,15 +13,7 @@ import { API_ENDPOINTS, getCurrentEndpoint, setApiEndpoint } from '@/lib/pocketb
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; 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';
import { ForgotPasswordDialog } from '@/components/auth/ForgotPasswordDialog';
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('');
@@ -27,168 +21,68 @@ const Login = () => {
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 [showForgotPassword, setShowForgotPassword] = useState(false);
const [loginAttempts, setLoginAttempts] = useState<LoginAttempts>({ count: 0, lastAttempt: 0 }); const [loginError, setLoginError] = useState('');
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();
// Add responsiveness check
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
// Check if we're in development mode
const isDevelopment = import.meta.env.DEV;
useEffect(() => { useEffect(() => {
const checkScreenSize = () => { const checkScreenSize = () => {
setIsMobile(window.innerWidth < 640); setIsMobile(window.innerWidth < 640);
}; };
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({}); setLoginError(''); // Clear previous errors
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 // Set specific error message based on the error
const remainingAttempts = MAX_LOGIN_ATTEMPTS - (loginAttempts.count + 1); if (error instanceof Error) {
let errorMessage = t("invalidCredentials"); if (error.message.includes('Failed to authenticate') ||
error.message.includes('Authentication failed') ||
if (remainingAttempts > 0 && remainingAttempts <= 2) { error.message.includes('Invalid credentials') ||
errorMessage += ` ${t("attemptsRemaining")}: ${remainingAttempts}`; error.message.includes('invalid email or password')) {
setLoginError(t("invalidCredentials"));
} else {
setLoginError(error.message);
}
} else {
setLoginError(`${t("authenticationFailed")}. Server: ${currentEndpoint}`);
} }
setErrors({ general: errorMessage });
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("loginFailed"), title: t("loginFailed"),
description: errorMessage, description: error instanceof Error
? (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 { } finally {
setLoading(false); setLoading(false);
@@ -204,175 +98,123 @@ 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 */}
<img
src="/checkcle_logo.svg"
alt="Checkcle Logo"
className="h-16 sm:h-20 mx-auto mb-4"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground">
{t("signInToYourAccount")}
</h1>
{isLocked && ( {/* Logo */}
<div className="mt-4 p-3 bg-destructive/10 border border-destructive/20 rounded-lg"> <div className="mb-4">
<div className="flex items-center gap-2 text-destructive text-sm"> <img
<AlertCircle className="h-4 w-4" /> src="/checkcle_logo.svg"
<span> alt="CheckCle Logo"
{t("accountLocked")} - {formatLockoutTime(lockoutTimeRemaining)} className="mx-auto h-16 w-auto"
</span> />
</div> </div>
</div>
)} <h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground">{t("signInToYourAccount")}</h1>
{/* Removed "Don't have an account? Create one" text */}
</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 && ( <form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg"> {/* Error Alert */}
<div className="flex items-center gap-2 text-destructive text-sm"> {loginError && (
<AlertCircle className="h-4 w-4" /> <Alert variant="destructive" className="mb-4">
<span>{errors.general}</span> <AlertCircle className="h-4 w-4" />
</div> <AlertDescription>{loginError}</AlertDescription>
</div> </Alert>
)} )}
<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) { setLoginError(''); // Clear error when user starts typing
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")} <button
</label> type="button"
<a onClick={() => setShowForgotPassword(true)}
href="#" className="text-xs sm:text-sm text-emerald-500 hover:text-emerald-400"
className="text-xs sm:text-sm text-primary hover:text-primary/80 transition-colors"
onClick={(e) => e.preventDefault()}
> >
{t("forgot")} {t("forgot")}
</a> </button>
</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) { setLoginError(''); // Clear error when user starts typing
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,38 +224,29 @@ 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>
<ForgotPasswordDialog
open={showForgotPassword}
onOpenChange={setShowForgotPassword}
/>
</div> </div>
); );
}; };
export default Login; export default Login;