Files
Hotel-Booking/Frontend/src/features/auth/pages/HousekeepingLoginPage.tsx
Iliyan Angelov b818d645a9 updates
2025-12-07 20:36:17 +02:00

572 lines
21 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { X, Eye, EyeOff, LogIn, Loader2, Mail, Lock, Shield, ArrowLeft, Sparkles } from 'lucide-react';
import { useNavigate, Link } from 'react-router-dom';
import useAuthStore from '../../../store/useAuthStore';
import { loginSchema, LoginFormData } from '../../../shared/utils/validationSchemas';
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
import * as yup from 'yup';
import { toast } from 'react-toastify';
import Recaptcha from '../../../shared/components/Recaptcha';
import { recaptchaService } from '../../../features/system/services/systemSettingsService';
import { useAntibotForm } from '../hooks/useAntibotForm';
import HoneypotField from '../../../shared/components/HoneypotField';
import authService from '../services/authService';
const mfaTokenSchema = yup.object().shape({
mfaToken: yup
.string()
.required('MFA token is required')
.min(6, 'MFA token must be 6 digits')
.max(8, 'MFA token must be 6-8 characters')
.matches(/^\d+$|^[A-Z0-9]{8}$/, 'Invalid token format'),
});
type MFATokenFormData = yup.InferType<typeof mfaTokenSchema>;
const HousekeepingLoginPage: React.FC = () => {
const { isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore();
const { settings } = useCompanySettings();
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const {
honeypotValue,
setHoneypotValue,
recaptchaToken,
setRecaptchaToken,
validate: validateAntibot,
rateLimitInfo,
} = useAntibotForm({
formId: 'housekeeping-login',
minTimeOnPage: 3000,
minTimeToFill: 2000,
requireRecaptcha: false,
maxAttempts: 5,
onValidationError: (errors) => {
errors.forEach((err) => toast.error(err));
},
});
const {
register: registerMFA,
handleSubmit: handleSubmitMFA,
formState: { errors: mfaErrors },
} = useForm<MFATokenFormData>({
resolver: yupResolver(mfaTokenSchema),
defaultValues: {
mfaToken: '',
},
});
useEffect(() => {
if (!isLoading && isAuthenticated && !requiresMFA && userInfo) {
const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase();
// Safety check - should not happen if onSubmit logic works correctly
if (role !== 'housekeeping') {
navigate(`/${role}/login`, { replace: true });
return;
}
navigate('/housekeeping/dashboard', { replace: true });
}
}, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: yupResolver(loginSchema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
const onSubmit = async (data: LoginFormData) => {
try {
clearError();
// Validate antibot protection
const isValid = await validateAntibot();
if (!isValid) {
return;
}
// Verify reCAPTCHA if token is provided
if (recaptchaToken) {
try {
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
toast.error('reCAPTCHA verification failed. Please try again.');
setRecaptchaToken(null);
return;
}
} catch (error) {
toast.error('reCAPTCHA verification failed. Please try again.');
setRecaptchaToken(null);
return;
}
}
// Call login API directly to check role BEFORE showing success toast
useAuthStore.setState({ isLoading: true, error: null });
try {
const response = await authService.login({
email: data.email,
password: data.password,
rememberMe: data.rememberMe,
expectedRole: 'housekeeping', // Housekeeping login page only accepts housekeeping
});
// Handle MFA requirement
if (response.requires_mfa) {
useAuthStore.setState({
isLoading: false,
requiresMFA: true,
mfaUserId: response.user_id || null,
pendingCredentials: {
email: data.email,
password: data.password,
rememberMe: data.rememberMe,
expectedRole: 'housekeeping',
},
error: null,
});
setRecaptchaToken(null);
return;
}
// Check if login was successful
if (response.success || response.status === 'success') {
const user = response.data?.user ?? null;
if (!user) {
throw new Error(response.message || 'Login failed.');
}
// Check role BEFORE setting authenticated state or showing success toast
const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase();
// Reject non-housekeeping roles - show error and don't authenticate
if (role !== 'housekeeping') {
// Call logout API to clear any server-side session
await authService.logout().catch(() => {
// Ignore logout errors
});
// Show error toast
toast.error(`This login is only for housekeeping staff. Please use the ${role} login page at /${role}/login to access your account.`, {
autoClose: 6000,
position: 'top-center',
toastId: 'role-error',
});
// Reset loading state
useAuthStore.setState({
isLoading: false,
error: 'Invalid role for this login page',
});
setRecaptchaToken(null);
// Navigate to the appropriate login page
setTimeout(() => {
navigate(`/${role}/login`, { replace: true });
}, 2000);
return;
}
// Only proceed with authentication if user is housekeeping
// Store minimal userInfo in localStorage
const minimalUserInfo = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
};
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
// Update auth store state
useAuthStore.setState({
token: null,
userInfo: user,
isAuthenticated: true,
isLoading: false,
error: null,
requiresMFA: false,
mfaUserId: null,
pendingCredentials: null,
});
// Show success toast only for housekeeping
toast.success('Login successful!');
setRecaptchaToken(null);
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || 'Login failed. Please try again.';
useAuthStore.setState({
isLoading: false,
error: errorMessage,
isAuthenticated: false,
requiresMFA: false,
mfaUserId: null,
pendingCredentials: null,
});
toast.error(errorMessage);
setRecaptchaToken(null);
}
} catch (error) {
if (import.meta.env.DEV) {
console.error('Login error:', error);
}
setRecaptchaToken(null);
}
};
const onSubmitMFA = async (data: MFATokenFormData) => {
try {
clearError();
// Get pending credentials from store
const state = useAuthStore.getState();
if (!state.pendingCredentials) {
toast.error('No pending login credentials');
return;
}
// Call MFA verification API directly to check role before showing success
useAuthStore.setState({ isLoading: true, error: null });
try {
const credentials = {
...state.pendingCredentials,
mfaToken: data.mfaToken,
expectedRole: 'housekeeping', // Housekeeping login page only accepts housekeeping
};
const response = await authService.login(credentials);
if (response.success || response.status === 'success') {
const user = response.data?.user ?? null;
if (!user) {
throw new Error(response.message || 'MFA verification failed.');
}
// Check role BEFORE setting authenticated state or showing success toast
const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase();
// Reject non-housekeeping roles - show error and don't authenticate
if (role !== 'housekeeping') {
// Call logout API to clear any server-side session
await authService.logout().catch(() => {
// Ignore logout errors
});
// Show error toast
toast.error(`This login is only for housekeeping staff. Please use the ${role} login page at /${role}/login to access your account.`, {
autoClose: 6000,
position: 'top-center',
toastId: 'role-error',
});
// Reset auth store state
useAuthStore.setState({
isLoading: false,
error: 'Invalid role for this login page',
requiresMFA: false,
mfaUserId: null,
pendingCredentials: null,
});
// Navigate to the appropriate login page
setTimeout(() => {
navigate(`/${role}/login`, { replace: true });
}, 2000);
return;
}
// Only proceed with authentication if user is housekeeping
// Store minimal userInfo in localStorage
const minimalUserInfo = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
};
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
// Update auth store state
useAuthStore.setState({
token: null,
userInfo: user,
isAuthenticated: true,
isLoading: false,
error: null,
requiresMFA: false,
mfaUserId: null,
pendingCredentials: null,
});
// Show success toast only for housekeeping
toast.success('Login successful!');
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || 'MFA verification failed. Please try again.';
useAuthStore.setState({
isLoading: false,
error: errorMessage,
requiresMFA: true, // Keep MFA state in case of error
});
toast.error(errorMessage);
}
} catch (error) {
if (import.meta.env.DEV) {
console.error('MFA verification error:', error);
}
}
};
const handleBackToLogin = () => {
clearMFA();
clearError();
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-2xl border border-gray-200/50 overflow-hidden">
<div className="bg-gradient-to-r from-[var(--luxury-gold)] via-[var(--luxury-gold-dark)] to-[var(--luxury-gold)] p-6 sm:p-8 text-center">
<div className="flex justify-center mb-4">
{settings.company_logo_url ? (
<img
src={settings.company_logo_url.startsWith('http')
? settings.company_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`}
alt={settings.company_name || 'Logo'}
className="h-16 w-auto max-w-[150px] object-contain bg-white/20 p-2 rounded-lg"
/>
) : (
<div className="relative p-3 bg-white/20 backdrop-blur-sm rounded-full">
<Sparkles className="w-10 h-10 text-white" />
</div>
)}
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-2">Housekeeping Login</h1>
<p className="text-white/90 text-sm">Access your housekeeping dashboard</p>
</div>
<div className="p-6 sm:p-8">
{requiresMFA ? (
<form onSubmit={handleSubmitMFA(onSubmitMFA)} className="space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="mfaToken" className="block text-sm font-medium text-gray-700 mb-2">
Authentication Code
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Shield className="h-5 w-5 text-gray-400" />
</div>
<input
{...registerMFA('mfaToken')}
id="mfaToken"
type="text"
autoComplete="one-time-code"
maxLength={8}
className={`w-full pl-10 pr-4 py-3 border rounded-lg text-center tracking-widest ${
mfaErrors.mfaToken ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-[var(--luxury-gold)]'
}`}
placeholder="000000"
/>
</div>
{mfaErrors.mfaToken && (
<p className="mt-1 text-sm text-red-600">{mfaErrors.mfaToken.message}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-white py-3 px-4 rounded-lg font-semibold hover:from-[var(--luxury-gold-light)] hover:to-[var(--luxury-gold)] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<Loader2 className="animate-spin mr-2 h-5 w-5" />
Verifying...
</>
) : (
<>
<Shield className="mr-2 h-5 w-5" />
Verify
</>
)}
</button>
<div className="text-center">
<button
type="button"
onClick={handleBackToLogin}
className="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Login
</button>
</div>
</form>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<HoneypotField value={honeypotValue} onChange={setHoneypotValue} />
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
{rateLimitInfo && !rateLimitInfo.allowed && (
<div className="bg-yellow-50 border border-yellow-200 text-yellow-700 px-4 py-3 rounded-lg text-sm">
<p className="font-medium">Too many login attempts.</p>
<p className="text-xs mt-1">
Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()}
</p>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('email')}
id="email"
type="email"
autoComplete="email"
className={`w-full pl-10 pr-4 py-3 border rounded-lg ${
errors.email ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-[var(--luxury-gold)]'
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('password')}
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
className={`w-full pl-10 pr-10 py-3 border rounded-lg ${
errors.password ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-[var(--luxury-gold)]'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div className="flex items-center">
<input
{...register('rememberMe')}
id="rememberMe"
type="checkbox"
className="h-4 w-4 text-[var(--luxury-gold)] focus:ring-[var(--luxury-gold)] border-gray-300 rounded"
/>
<label htmlFor="rememberMe" className="ml-2 block text-sm text-gray-700">
Remember me
</label>
</div>
<div className="flex justify-center">
<Recaptcha
onChange={(token) => setRecaptchaToken(token)}
onError={(error) => {
if (import.meta.env.DEV) {
console.error('reCAPTCHA error:', error);
}
setRecaptchaToken(null);
}}
theme="light"
size="normal"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-white py-3 px-4 rounded-lg font-semibold hover:from-[var(--luxury-gold-light)] hover:to-[var(--luxury-gold)] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<Loader2 className="animate-spin mr-2 h-5 w-5" />
Processing...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
Sign In
</>
)}
</button>
</form>
)}
<div className="mt-6 text-center">
<Link
to="/"
className="text-sm text-gray-600 hover:text-gray-900"
>
Back to Home
</Link>
</div>
</div>
</div>
</div>
</div>
);
};
export default HousekeepingLoginPage;