572 lines
21 KiB
TypeScript
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;
|
|
|