Qassure-frontend/src/pages/Login.tsx

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;