232 lines
8.0 KiB
TypeScript
232 lines
8.0 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import type { ReactElement } from 'react';
|
|
import { Shield } from 'lucide-react';
|
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
|
import { loginAsync, clearError } from '@/store/authSlice';
|
|
import { FormField } from '@/components/shared';
|
|
import { PrimaryButton } from '@/components/shared';
|
|
import type { LoginError } from '@/services/auth-service';
|
|
import { showToast } from '@/utils/toast';
|
|
|
|
// Zod validation schema
|
|
const loginSchema = z.object({
|
|
email: z
|
|
.string()
|
|
.min(1, 'Email is required')
|
|
.email('Please enter a valid email address'),
|
|
password: z
|
|
.string()
|
|
.min(1, 'Password is required')
|
|
.min(6, 'Password must be at least 6 characters'),
|
|
});
|
|
|
|
type LoginFormData = z.infer<typeof loginSchema>;
|
|
|
|
const Login = (): ReactElement => {
|
|
const navigate = useNavigate();
|
|
const dispatch = useAppDispatch();
|
|
const { isLoading, error, isAuthenticated } = useAppSelector((state) => state.auth);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
setError,
|
|
formState: { errors },
|
|
clearErrors,
|
|
} = useForm<LoginFormData>({
|
|
resolver: zodResolver(loginSchema),
|
|
mode: 'onBlur', // Validate on blur for better UX
|
|
});
|
|
|
|
const [generalError, setGeneralError] = useState<string>('');
|
|
|
|
// Redirect if already authenticated
|
|
useEffect(() => {
|
|
if (isAuthenticated) {
|
|
navigate('/dashboard');
|
|
}
|
|
}, [isAuthenticated, navigate]);
|
|
|
|
// Clear errors only on component mount, not on every auth state change
|
|
useEffect(() => {
|
|
// Only clear errors on initial mount
|
|
dispatch(clearError());
|
|
setGeneralError('');
|
|
clearErrors();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []); // Empty dependency array - only run on mount
|
|
|
|
const onSubmit = async (data: LoginFormData): Promise<void> => {
|
|
// Clear previous errors
|
|
setGeneralError('');
|
|
clearErrors();
|
|
dispatch(clearError());
|
|
|
|
try {
|
|
const result = await dispatch(loginAsync(data)).unwrap();
|
|
if (result) {
|
|
const message = result.message || 'Login successful';
|
|
const description = result.message ? undefined : 'Welcome back!';
|
|
showToast.success(message, description);
|
|
// Only navigate on success
|
|
navigate('/dashboard');
|
|
}
|
|
} catch (error: any) {
|
|
// Clear Redux error state since we're handling errors locally
|
|
dispatch(clearError());
|
|
|
|
// Handle error from unwrap() - error is the rejected value from rejectWithValue
|
|
const loginError = error as LoginError;
|
|
|
|
if (loginError && typeof loginError === 'object') {
|
|
// Check for validation errors with details array
|
|
if ('details' in loginError && Array.isArray(loginError.details)) {
|
|
// Validation errors from server - set field-specific errors
|
|
loginError.details.forEach((detail) => {
|
|
if (detail.path === 'email' || detail.path === 'password') {
|
|
setError(detail.path as keyof LoginFormData, {
|
|
type: 'server',
|
|
message: detail.message,
|
|
});
|
|
} else {
|
|
// If error is for a field we don't handle, show as general error
|
|
setGeneralError(detail.message);
|
|
}
|
|
});
|
|
} else if ('error' in loginError) {
|
|
// Check if error is an object with message property
|
|
if (typeof loginError.error === 'object' && loginError.error !== null && 'message' in loginError.error) {
|
|
// General error from server with object structure
|
|
setGeneralError(loginError.error.message || 'Login failed');
|
|
} else if (typeof loginError.error === 'string') {
|
|
// Error is a string
|
|
setGeneralError(loginError.error);
|
|
} else {
|
|
setGeneralError('Login failed. Please check your credentials.');
|
|
}
|
|
} else {
|
|
// Fallback for unknown error structure
|
|
setGeneralError('An unexpected error occurred. Please try again.');
|
|
}
|
|
} else if (error?.message) {
|
|
// Network error or other error
|
|
setGeneralError(error.message);
|
|
} else {
|
|
// Complete fallback
|
|
setGeneralError('Login failed. Please check your credentials and try again.');
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-[#f6f9ff] px-4">
|
|
<div className="w-full max-w-md">
|
|
{/* Login Card */}
|
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-6 md:p-8">
|
|
{/* Logo Section */}
|
|
<div className="flex justify-center mb-8">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 bg-[#112868] rounded-xl flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)]">
|
|
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
|
</div>
|
|
<div className="text-2xl font-bold text-[#0f1724] tracking-[-0.4px]">
|
|
QAssure
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2">
|
|
Welcome Back 1
|
|
</h1>
|
|
<p className="text-sm md:text-base text-[#6b7280]">
|
|
Sign in to your account to continue
|
|
</p>
|
|
</div>
|
|
|
|
{/* General Error Message - Prioritize local error over Redux error */}
|
|
{generalError && (
|
|
<div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
|
<p className="text-sm text-[#ef4444]">{generalError}</p>
|
|
</div>
|
|
)}
|
|
{/* Show Redux error only if no local error and no field errors */}
|
|
{!generalError && error && !errors.email && !errors.password && (
|
|
<div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
|
<p className="text-sm text-[#ef4444]">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleSubmit(onSubmit)(e);
|
|
}}
|
|
className="space-y-4"
|
|
noValidate
|
|
>
|
|
{/* Email Field */}
|
|
<FormField
|
|
label="Email"
|
|
type="email"
|
|
placeholder="Enter your email"
|
|
required
|
|
error={errors.email?.message}
|
|
{...register('email')}
|
|
/>
|
|
|
|
{/* Password Field */}
|
|
<FormField
|
|
label="Password"
|
|
type="password"
|
|
placeholder="Enter your password"
|
|
required
|
|
error={errors.password?.message}
|
|
{...register('password')}
|
|
/>
|
|
|
|
{/* Forgot Password Link */}
|
|
<div className="flex justify-end -mt-2">
|
|
<a
|
|
href="/forgot-password"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
navigate('/forgot-password');
|
|
}}
|
|
className="text-sm text-[#112868] hover:text-[#0d1f4d] transition-colors"
|
|
>
|
|
Forgot Password?
|
|
</a>
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<div className="pt-2">
|
|
<PrimaryButton
|
|
type="submit"
|
|
size="large"
|
|
disabled={isLoading}
|
|
className="w-full h-12 text-sm font-medium"
|
|
>
|
|
{isLoading ? 'Signing in...' : 'Sign In'}
|
|
</PrimaryButton>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="mt-6 text-center">
|
|
<p className="text-xs md:text-sm text-[#6b7280]">
|
|
© 2026 QAssure. All rights reserved.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Login;
|