diff --git a/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc b/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc index 0d30d28d..a53358e2 100644 Binary files a/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc and b/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc differ diff --git a/Backend/src/auth/routes/auth_routes.py b/Backend/src/auth/routes/auth_routes.py index 0c196ae4..9053b261 100644 --- a/Backend/src/auth/routes/auth_routes.py +++ b/Backend/src/auth/routes/auth_routes.py @@ -149,11 +149,39 @@ async def login( request_id = getattr(request.state, 'request_id', None) try: - result = await auth_service.login(db=db, email=login_request.email, password=login_request.password, remember_me=login_request.rememberMe or False, mfa_token=login_request.mfaToken) + result = await auth_service.login( + db=db, + email=login_request.email, + password=login_request.password, + remember_me=login_request.rememberMe or False, + mfa_token=login_request.mfaToken, + expected_role=login_request.expectedRole + ) if result.get('requires_mfa'): # Log MFA required user = db.query(User).filter(User.email == login_request.email.lower().strip()).first() if user: + # Validate role even for MFA flow if expected_role is provided + if login_request.expectedRole and user.role: + user_role = user.role.name.lower() if hasattr(user.role, 'name') else None + expected_role = login_request.expectedRole.lower() + if user_role != expected_role: + await audit_service.log_action( + db=db, + action='login_role_mismatch', + resource_type='authentication', + user_id=user.id, + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': login_request.email, 'expected_role': expected_role, 'actual_role': user_role}, + status='failed' + ) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={'status': 'error', 'message': f'This login endpoint is only for {expected_role} users'} + ) + await audit_service.log_action( db=db, action='login_mfa_required', @@ -212,6 +240,31 @@ async def login( logger = get_logger(__name__) logger.warning(f'Failed to create session during login: {str(e)}') + # Validate role if expected_role is provided (for role-specific login endpoints) + if login_request.expectedRole: + user_role = result['user'].get('role', '').lower() + expected_role = login_request.expectedRole.lower() + if user_role != expected_role: + # Log security event + await audit_service.log_action( + db=db, + action='login_role_mismatch', + resource_type='authentication', + user_id=result['user']['id'], + ip_address=client_ip, + user_agent=user_agent, + request_id=request_id, + details={'email': login_request.email, 'expected_role': expected_role, 'actual_role': user_role}, + status='failed' + ) + # Clear cookies on role mismatch + response.delete_cookie(key='refreshToken', path='/', secure=settings.is_production, samesite=samesite_value) + response.delete_cookie(key='accessToken', path='/', secure=settings.is_production, samesite=samesite_value) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={'status': 'error', 'message': f'This login endpoint is only for {expected_role} users'} + ) + # Log successful login await audit_service.log_action( db=db, @@ -229,9 +282,25 @@ async def login( return {'status': 'success', 'data': {'user': result['user']}} except ValueError as e: error_message = str(e) - status_code = status.HTTP_401_UNAUTHORIZED if 'Invalid email or password' in error_message or 'Invalid MFA token' in error_message else status.HTTP_400_BAD_REQUEST + # SECURITY: Sanitize error messages to prevent information disclosure + # Don't reveal specific details about account status, remaining attempts, etc. to unauthenticated users + sanitized_message = error_message - # Log failed login attempt + # Generic error messages for authentication failures + if 'Invalid email or password' in error_message or 'Invalid MFA token' in error_message: + sanitized_message = 'Invalid email or password' + status_code = status.HTTP_401_UNAUTHORIZED + elif 'Account is disabled' in error_message: + sanitized_message = 'Account is disabled. Please contact support.' + status_code = status.HTTP_403_FORBIDDEN + elif 'Account is temporarily locked' in error_message or 'Account has been temporarily locked' in error_message: + # Keep lockout message but remove specific timing details for security + sanitized_message = 'Account is temporarily locked due to multiple failed login attempts. Please try again later.' + status_code = status.HTTP_403_FORBIDDEN + else: + status_code = status.HTTP_400_BAD_REQUEST + + # Log failed login attempt with full details (for internal logging) await audit_service.log_action( db=db, action='login_failed', @@ -241,10 +310,10 @@ async def login( request_id=request_id, details={'email': login_request.email}, status='failed', - error_message=error_message + error_message=error_message # Log full error internally ) - return JSONResponse(status_code=status_code, content={'status': 'error', 'message': error_message}) + return JSONResponse(status_code=status_code, content={'status': 'error', 'message': sanitized_message}) @router.post('/refresh-token', response_model=TokenResponse) async def refresh_token( diff --git a/Backend/src/auth/schemas/__pycache__/auth.cpython-312.pyc b/Backend/src/auth/schemas/__pycache__/auth.cpython-312.pyc index 4d28c292..548ca9ea 100644 Binary files a/Backend/src/auth/schemas/__pycache__/auth.cpython-312.pyc and b/Backend/src/auth/schemas/__pycache__/auth.cpython-312.pyc differ diff --git a/Backend/src/auth/schemas/auth.py b/Backend/src/auth/schemas/auth.py index ca777efb..144b6ea4 100644 --- a/Backend/src/auth/schemas/auth.py +++ b/Backend/src/auth/schemas/auth.py @@ -32,6 +32,7 @@ class LoginRequest(BaseModel): password: str rememberMe: Optional[bool] = False mfaToken: Optional[str] = None + expectedRole: Optional[str] = None # Optional role validation for role-specific login endpoints class RefreshTokenRequest(BaseModel): refreshToken: Optional[str] = None diff --git a/Backend/src/auth/services/__pycache__/auth_service.cpython-312.pyc b/Backend/src/auth/services/__pycache__/auth_service.cpython-312.pyc index 44ab9a54..07bf488c 100644 Binary files a/Backend/src/auth/services/__pycache__/auth_service.cpython-312.pyc and b/Backend/src/auth/services/__pycache__/auth_service.cpython-312.pyc differ diff --git a/Backend/src/auth/services/auth_service.py b/Backend/src/auth/services/auth_service.py index 55e9d4fc..244ecd15 100644 --- a/Backend/src/auth/services/auth_service.py +++ b/Backend/src/auth/services/auth_service.py @@ -46,6 +46,16 @@ class AuthService: else: logger.warning(error_msg) + # SECURITY: Validate JWT secret entropy (check for predictable patterns) + # Check if secret appears to be randomly generated (not a simple pattern) + if len(set(self.jwt_secret)) < len(self.jwt_secret) * 0.3: # Less than 30% unique characters suggests low entropy + error_msg = 'JWT_SECRET appears to have low entropy. Please use a randomly generated secret.' + logger.error(error_msg) + if settings.is_production: + raise ValueError(error_msg) + else: + logger.warning(error_msg) + # Refresh secret should be different from access secret self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET") if not self.jwt_refresh_secret: @@ -187,7 +197,7 @@ class AuthService: "refreshToken": tokens["refreshToken"] } - async def login(self, db: Session, email: str, password: str, remember_me: bool = False, mfa_token: str = None) -> dict: + async def login(self, db: Session, email: str, password: str, remember_me: bool = False, mfa_token: str = None, expected_role: str = None) -> dict: email = email.lower().strip() if email else "" if not email: @@ -206,9 +216,9 @@ class AuthService: # Check if account is locked (reset if lockout expired) if user.locked_until: if user.locked_until > datetime.utcnow(): - remaining_minutes = int((user.locked_until - datetime.utcnow()).total_seconds() / 60) logger.warning(f"Login attempt for locked account: {email} (locked until {user.locked_until})") - raise ValueError(f"Account is temporarily locked due to multiple failed login attempts. Please try again in {remaining_minutes} minute(s).") + # SECURITY: Don't reveal specific lockout duration to prevent timing attacks + raise ValueError("Account is temporarily locked due to multiple failed login attempts. Please try again later.") else: # Lockout expired, reset it user.locked_until = None @@ -230,12 +240,13 @@ class AuthService: user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration) logger.warning(f"Account locked due to {user.failed_login_attempts} failed login attempts: {email}") db.commit() - raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).") + # SECURITY: Don't reveal specific lockout duration + raise ValueError("Account has been temporarily locked due to multiple failed login attempts. Please try again later.") else: - remaining_attempts = max_attempts - user.failed_login_attempts logger.warning(f"Login attempt with invalid password for user: {email} ({user.failed_login_attempts}/{max_attempts} failed attempts)") db.commit() - raise ValueError(f"Invalid email or password. {remaining_attempts} attempt(s) remaining before account lockout.") + # SECURITY: Don't reveal remaining attempts to prevent enumeration + raise ValueError("Invalid email or password") if user.mfa_enabled: if not mfa_token: @@ -258,12 +269,21 @@ class AuthService: user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration) logger.warning(f"Account locked due to {user.failed_login_attempts} failed attempts (MFA failure): {email}") db.commit() - raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).") + # SECURITY: Don't reveal specific lockout duration + raise ValueError("Account has been temporarily locked due to multiple failed login attempts. Please try again later.") else: - remaining_attempts = max_attempts - user.failed_login_attempts db.commit() - raise ValueError(f"Invalid MFA token. {remaining_attempts} attempt(s) remaining before account lockout.") + # SECURITY: Don't reveal remaining attempts + raise ValueError("Invalid MFA token") + # Validate role if expected_role is provided (for role-specific login endpoints) + if expected_role: + user_role = user.role.name.lower() if user.role and hasattr(user.role, 'name') else None + expected_role_lower = expected_role.lower() + if user_role != expected_role_lower: + logger.warning(f"Role mismatch: user {email} has role {user_role} but expected {expected_role_lower}") + raise ValueError(f"This login endpoint is only for {expected_role_lower} users") + # Reset failed login attempts and unlock account on successful login if user.failed_login_attempts > 0 or user.locked_until: user.failed_login_attempts = 0 @@ -447,7 +467,8 @@ class AuthService: db.query(PasswordResetToken).filter(PasswordResetToken.user_id == user.id).delete() - expires_at = datetime.utcnow() + timedelta(hours=1) + # SECURITY: Shorter expiration for password reset tokens (15 minutes instead of 1 hour) + expires_at = datetime.utcnow() + timedelta(minutes=15) reset_token_obj = PasswordResetToken( user_id=user.id, token=hashed_token, diff --git a/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc b/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc index ed18b301..fe7f2ed7 100644 Binary files a/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc and b/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc differ diff --git a/Backend/src/security/middleware/auth.py b/Backend/src/security/middleware/auth.py index f73bae08..e585cd9a 100644 --- a/Backend/src/security/middleware/auth.py +++ b/Backend/src/security/middleware/auth.py @@ -50,6 +50,18 @@ def get_jwt_secret() -> str: else: logger.warning(error_msg) + # SECURITY: Validate JWT secret entropy (check for predictable patterns) + # Check if secret appears to be randomly generated (not a simple pattern) + if len(set(jwt_secret)) < len(jwt_secret) * 0.3: # Less than 30% unique characters suggests low entropy + error_msg = 'JWT_SECRET appears to have low entropy. Please use a randomly generated secret.' + import logging + logger = logging.getLogger(__name__) + logger.error(error_msg) + if settings.is_production: + raise ValueError(error_msg) + else: + logger.warning(error_msg) + return jwt_secret def get_current_user( diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index cbe8f85e..b2759a29 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -133,6 +133,12 @@ const AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage')); const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage')); const AccountantProfilePage = lazy(() => import('./pages/accountant/ProfilePage')); +// Separate login pages for each role +const StaffLoginPage = lazy(() => import('./features/auth/pages/StaffLoginPage')); +const AdminLoginPage = lazy(() => import('./features/auth/pages/AdminLoginPage')); +const HousekeepingLoginPage = lazy(() => import('./features/auth/pages/HousekeepingLoginPage')); +const AccountantLoginPage = lazy(() => import('./features/auth/pages/AccountantLoginPage')); + const NotFoundPage = lazy(() => import('./shared/pages/NotFoundPage')); // Component to track navigation changes - must be inside Router @@ -465,6 +471,40 @@ function App() { /> + {/* Separate Login Pages for Each Role */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {} = ({ children }) => { const { isAuthenticated, userInfo, isLoading } = useAuthStore(); - const { openModal } = useAuthModal(); - - // Open login modal if not authenticated - useEffect(() => { - if (!isLoading && !isAuthenticated) { - openModal('login'); - } - }, [isLoading, isAuthenticated, openModal]); if (isLoading) { return ( @@ -39,9 +30,9 @@ const AccountantRoute: React.FC = ({ ); } - // Don't render children if not authenticated (modal will be shown) + // Don't render children if not authenticated - redirect to login page if (!isAuthenticated) { - return null; // Modal will be shown by AuthModalManager + return ; } // Check if user is accountant diff --git a/Frontend/src/features/auth/components/AdminRoute.tsx b/Frontend/src/features/auth/components/AdminRoute.tsx index a3556cc4..5c5420a7 100644 --- a/Frontend/src/features/auth/components/AdminRoute.tsx +++ b/Frontend/src/features/auth/components/AdminRoute.tsx @@ -1,7 +1,6 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Navigate } from 'react-router-dom'; import useAuthStore from '../../../store/useAuthStore'; -import { useAuthModal } from '../contexts/AuthModalContext'; interface AdminRouteProps { children: React.ReactNode; @@ -19,14 +18,6 @@ const AdminRoute: React.FC = ({ children }) => { const { isAuthenticated, userInfo, isLoading } = useAuthStore(); - const { openModal } = useAuthModal(); - - // SECURITY: Client-side role check - backend must also validate - useEffect(() => { - if (!isLoading && !isAuthenticated) { - openModal('login'); - } - }, [isLoading, isAuthenticated, openModal]); if (isLoading) { return ( @@ -49,7 +40,7 @@ const AdminRoute: React.FC = ({ if (!isAuthenticated) { - return null; // Modal will be shown by AuthModalManager + return ; } diff --git a/Frontend/src/features/auth/components/ForgotPasswordModal.tsx b/Frontend/src/features/auth/components/ForgotPasswordModal.tsx index 62374f45..a525ad37 100644 --- a/Frontend/src/features/auth/components/ForgotPasswordModal.tsx +++ b/Frontend/src/features/auth/components/ForgotPasswordModal.tsx @@ -63,7 +63,9 @@ const ForgotPasswordModal: React.FC = () => { await forgotPassword({ email: data.email }); setIsSuccess(true); } catch (error) { - console.error('Forgot password error:', error); + if (import.meta.env.DEV) { + console.error('Forgot password error:', error); + } } }; diff --git a/Frontend/src/features/auth/components/HousekeepingRoute.tsx b/Frontend/src/features/auth/components/HousekeepingRoute.tsx index fbb51410..85c34813 100644 --- a/Frontend/src/features/auth/components/HousekeepingRoute.tsx +++ b/Frontend/src/features/auth/components/HousekeepingRoute.tsx @@ -1,7 +1,6 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Navigate } from 'react-router-dom'; import useAuthStore from '../../../store/useAuthStore'; -import { useAuthModal } from '../contexts/AuthModalContext'; interface HousekeepingRouteProps { children: React.ReactNode; @@ -9,13 +8,6 @@ interface HousekeepingRouteProps { const HousekeepingRoute: React.FC = ({ children }) => { const { isAuthenticated, userInfo, isLoading } = useAuthStore(); - const { openModal } = useAuthModal(); - - useEffect(() => { - if (!isLoading && !isAuthenticated) { - openModal('login'); - } - }, [isLoading, isAuthenticated, openModal]); if (isLoading) { return ( @@ -29,7 +21,7 @@ const HousekeepingRoute: React.FC = ({ children }) => { } if (!isAuthenticated) { - return null; // Modal will be shown by AuthModalManager + return ; } // Only allow housekeeping role - no admin or staff access diff --git a/Frontend/src/features/auth/components/LoginModal.tsx b/Frontend/src/features/auth/components/LoginModal.tsx index af75f25a..6a0f20cf 100644 --- a/Frontend/src/features/auth/components/LoginModal.tsx +++ b/Frontend/src/features/auth/components/LoginModal.tsx @@ -13,6 +13,7 @@ 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 @@ -27,7 +28,7 @@ type MFATokenFormData = yup.InferType; const LoginModal: React.FC = () => { const { closeModal, openModal } = useAuthModal(); - const { login, verifyMFA, isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore(); + const { isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore(); const { settings } = useCompanySettings(); const navigate = useNavigate(); @@ -63,26 +64,24 @@ const LoginModal: React.FC = () => { }, }); - // Close modal and redirect to appropriate dashboard on successful authentication + // Close modal and redirect to customer dashboard on successful authentication + // This modal is ONLY for customers - other roles have separate login pages + // This is a safety check in case user navigates back or state changes useEffect(() => { if (!isLoading && isAuthenticated && !requiresMFA && userInfo) { - closeModal(); - - // Redirect to role-specific dashboard const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase(); - if (role === 'admin') { - navigate('/admin/dashboard', { replace: true }); - } else if (role === 'staff') { - navigate('/staff/dashboard', { replace: true }); - } else if (role === 'accountant') { - navigate('/accountant/dashboard', { replace: true }); - } else if (role === 'housekeeping') { - navigate('/housekeeping/dashboard', { replace: true }); - } else { - // Customer or default - go to customer dashboard - navigate('/dashboard', { replace: true }); + // Reject non-customer roles - they should use their dedicated login pages + // This should not happen if onSubmit logic works correctly, but handle it as safety + if (role === 'admin' || role === 'staff' || role === 'accountant' || role === 'housekeeping') { + closeModal(); + navigate(`/${role}/login`, { replace: true }); + return; } + + // Only allow customers - close modal and redirect to dashboard + closeModal(); + navigate('/dashboard', { replace: true }); } }, [isLoading, isAuthenticated, requiresMFA, userInfo, closeModal, navigate]); @@ -125,15 +124,120 @@ const LoginModal: React.FC = () => { } } - await login({ - email: data.email, - password: data.password, - rememberMe: data.rememberMe, - }); + // 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: 'customer', // Customer login page only accepts customers + }); - setRecaptchaToken(null); + // 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: 'customer', + }, + 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-customer roles - show error and don't authenticate + if (role === 'admin' || role === 'staff' || role === 'accountant' || 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 customers. 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); + closeModal(); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is a customer + // 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 customers + 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) { - console.error('Login error:', error); + if (import.meta.env.DEV) { + console.error('Login error:', error); + } setRecaptchaToken(null); } }; @@ -141,7 +245,103 @@ const LoginModal: React.FC = () => { const onSubmitMFA = async (data: MFATokenFormData) => { try { clearError(); - await verifyMFA(data.mfaToken); + + // 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: 'customer', // Customer login page only accepts customers + }; + + 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-customer roles - show error and don't authenticate + if (role === 'admin' || role === 'staff' || role === 'accountant' || 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 customers. 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, + }); + + closeModal(); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is a customer + // 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 customers + 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) { console.error('MFA verification error:', error); } @@ -397,7 +597,9 @@ const LoginModal: React.FC = () => { setRecaptchaToken(token)} onError={(error) => { - console.error('reCAPTCHA error:', error); + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } setRecaptchaToken(null); }} theme="light" diff --git a/Frontend/src/features/auth/components/RegisterModal.tsx b/Frontend/src/features/auth/components/RegisterModal.tsx index 56147636..e4f841e9 100644 --- a/Frontend/src/features/auth/components/RegisterModal.tsx +++ b/Frontend/src/features/auth/components/RegisterModal.tsx @@ -134,7 +134,9 @@ const RegisterModal: React.FC = () => { setRecaptchaToken(null); } catch (error) { - console.error('Register error:', error); + if (import.meta.env.DEV) { + console.error('Register error:', error); + } setRecaptchaToken(null); } }; @@ -407,7 +409,9 @@ const RegisterModal: React.FC = () => { setRecaptchaToken(token)} onError={(error) => { - console.error('reCAPTCHA error:', error); + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } setRecaptchaToken(null); }} theme="light" diff --git a/Frontend/src/features/auth/components/ResetPasswordModal.tsx b/Frontend/src/features/auth/components/ResetPasswordModal.tsx index ad0007a8..10227440 100644 --- a/Frontend/src/features/auth/components/ResetPasswordModal.tsx +++ b/Frontend/src/features/auth/components/ResetPasswordModal.tsx @@ -95,7 +95,9 @@ const ResetPasswordModal: React.FC = ({ token }) => { }); setIsSuccess(true); } catch (error) { - console.error('Reset password error:', error); + if (import.meta.env.DEV) { + console.error('Reset password error:', error); + } } }; diff --git a/Frontend/src/features/auth/components/StaffRoute.tsx b/Frontend/src/features/auth/components/StaffRoute.tsx index 76f3680f..1d9c3767 100644 --- a/Frontend/src/features/auth/components/StaffRoute.tsx +++ b/Frontend/src/features/auth/components/StaffRoute.tsx @@ -1,7 +1,6 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Navigate } from 'react-router-dom'; import useAuthStore from '../../../store/useAuthStore'; -import { useAuthModal } from '../contexts/AuthModalContext'; interface StaffRouteProps { children: React.ReactNode; @@ -11,14 +10,6 @@ const StaffRoute: React.FC = ({ children }) => { const { isAuthenticated, userInfo, isLoading } = useAuthStore(); - const { openModal } = useAuthModal(); - - - useEffect(() => { - if (!isLoading && !isAuthenticated) { - openModal('login'); - } - }, [isLoading, isAuthenticated, openModal]); if (isLoading) { return ( @@ -41,7 +32,7 @@ const StaffRoute: React.FC = ({ if (!isAuthenticated) { - return null; // Modal will be shown by AuthModalManager + return ; } diff --git a/Frontend/src/features/auth/hooks/useAntibotForm.ts b/Frontend/src/features/auth/hooks/useAntibotForm.ts index 599d7c9b..41fe9647 100644 --- a/Frontend/src/features/auth/hooks/useAntibotForm.ts +++ b/Frontend/src/features/auth/hooks/useAntibotForm.ts @@ -112,7 +112,9 @@ export const useAntibotForm = (options: UseAntibotFormOptions): UseAntibotFormRe setIsValidating(false); return true; } catch (error) { - console.error('Antibot validation error:', error); + if (import.meta.env.DEV) { + console.error('Antibot validation error:', error); + } setIsValidating(false); return false; } diff --git a/Frontend/src/features/auth/pages/AccountantLoginPage.tsx b/Frontend/src/features/auth/pages/AccountantLoginPage.tsx new file mode 100644 index 00000000..3944d112 --- /dev/null +++ b/Frontend/src/features/auth/pages/AccountantLoginPage.tsx @@ -0,0 +1,571 @@ +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, Calculator } 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; + +const AccountantLoginPage: 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: 'accountant-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({ + 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 !== 'accountant') { + navigate(`/${role}/login`, { replace: true }); + return; + } + + navigate('/accountant/dashboard', { replace: true }); + } + }, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + 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: 'accountant', // Accountant login page only accepts accountants + }); + + // 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: 'accountant', + }, + 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-accountant roles - show error and don't authenticate + if (role !== 'accountant') { + // 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 accountants. 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 accountant + // 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 accountants + 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: 'accountant', // Accountant login page only accepts accountants + }; + + 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-accountant roles - show error and don't authenticate + if (role !== 'accountant') { + // 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 accountants. 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 accountant + // 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 accountants + 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 ( +
+
+
+
+
+ {settings.company_logo_url ? ( + {settings.company_name + ) : ( +
+ +
+ )} +
+

Accountant Login

+

Access your accountant dashboard

+
+ +
+ {requiresMFA ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+
+ +
+ +
+ {mfaErrors.mfaToken && ( +

{mfaErrors.mfaToken.message}

+ )} +
+ + + +
+ +
+
+ ) : ( +
+ + + {error && ( +
+ {error} +
+ )} + + {rateLimitInfo && !rateLimitInfo.allowed && ( +
+

Too many login attempts.

+

+ Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()} +

+
+ )} + +
+ +
+
+ +
+ +
+ {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + +
+ +
+ setRecaptchaToken(token)} + onError={(error) => { + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } + setRecaptchaToken(null); + }} + theme="light" + size="normal" + /> +
+ + + + )} + +
+ + ← Back to Home + +
+
+
+
+
+ ); +}; + +export default AccountantLoginPage; + diff --git a/Frontend/src/features/auth/pages/AdminLoginPage.tsx b/Frontend/src/features/auth/pages/AdminLoginPage.tsx new file mode 100644 index 00000000..afe0b26b --- /dev/null +++ b/Frontend/src/features/auth/pages/AdminLoginPage.tsx @@ -0,0 +1,571 @@ +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, Settings } 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; + +const AdminLoginPage: 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: 'admin-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({ + 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 !== 'admin') { + navigate(`/${role}/login`, { replace: true }); + return; + } + + navigate('/admin/dashboard', { replace: true }); + } + }, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + 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: 'admin', // Admin login page only accepts admins + }); + + // 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: 'admin', + }, + 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-admin roles - show error and don't authenticate + if (role !== 'admin') { + // 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 administrators. 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 an admin + // 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 admins + 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: 'admin', // Admin login page only accepts admins + }; + + 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-admin roles - show error and don't authenticate + if (role !== 'admin') { + // 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 administrators. 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 an admin + // 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 admins + 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 ( +
+
+
+
+
+ {settings.company_logo_url ? ( + {settings.company_name + ) : ( +
+ +
+ )} +
+

Admin Login

+

Access your admin dashboard

+
+ +
+ {requiresMFA ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+
+ +
+ +
+ {mfaErrors.mfaToken && ( +

{mfaErrors.mfaToken.message}

+ )} +
+ + + +
+ +
+
+ ) : ( +
+ + + {error && ( +
+ {error} +
+ )} + + {rateLimitInfo && !rateLimitInfo.allowed && ( +
+

Too many login attempts.

+

+ Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()} +

+
+ )} + +
+ +
+
+ +
+ +
+ {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + +
+ +
+ setRecaptchaToken(token)} + onError={(error) => { + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } + setRecaptchaToken(null); + }} + theme="light" + size="normal" + /> +
+ + + + )} + +
+ + ← Back to Home + +
+
+
+
+
+ ); +}; + +export default AdminLoginPage; + diff --git a/Frontend/src/features/auth/pages/HousekeepingLoginPage.tsx b/Frontend/src/features/auth/pages/HousekeepingLoginPage.tsx new file mode 100644 index 00000000..5605b5fa --- /dev/null +++ b/Frontend/src/features/auth/pages/HousekeepingLoginPage.tsx @@ -0,0 +1,571 @@ +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; + +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({ + 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({ + 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 ( +
+
+
+
+
+ {settings.company_logo_url ? ( + {settings.company_name + ) : ( +
+ +
+ )} +
+

Housekeeping Login

+

Access your housekeeping dashboard

+
+ +
+ {requiresMFA ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+
+ +
+ +
+ {mfaErrors.mfaToken && ( +

{mfaErrors.mfaToken.message}

+ )} +
+ + + +
+ +
+
+ ) : ( +
+ + + {error && ( +
+ {error} +
+ )} + + {rateLimitInfo && !rateLimitInfo.allowed && ( +
+

Too many login attempts.

+

+ Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()} +

+
+ )} + +
+ +
+
+ +
+ +
+ {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + +
+ +
+ setRecaptchaToken(token)} + onError={(error) => { + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } + setRecaptchaToken(null); + }} + theme="light" + size="normal" + /> +
+ + + + )} + +
+ + ← Back to Home + +
+
+
+
+
+ ); +}; + +export default HousekeepingLoginPage; + diff --git a/Frontend/src/features/auth/pages/StaffLoginPage.tsx b/Frontend/src/features/auth/pages/StaffLoginPage.tsx new file mode 100644 index 00000000..a2e9c8b6 --- /dev/null +++ b/Frontend/src/features/auth/pages/StaffLoginPage.tsx @@ -0,0 +1,576 @@ +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, User } 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; + +const StaffLoginPage: React.FC = () => { + const { isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore(); + const { settings } = useCompanySettings(); + const navigate = useNavigate(); + + const [showPassword, setShowPassword] = useState(false); + + // Enhanced antibot protection + const { + honeypotValue, + setHoneypotValue, + recaptchaToken, + setRecaptchaToken, + validate: validateAntibot, + rateLimitInfo, + } = useAntibotForm({ + formId: 'staff-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({ + resolver: yupResolver(mfaTokenSchema), + defaultValues: { + mfaToken: '', + }, + }); + + // Redirect to staff dashboard on successful authentication + 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 !== 'staff') { + navigate(`/${role}/login`, { replace: true }); + return; + } + + navigate('/staff/dashboard', { replace: true }); + } + }, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + 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: 'staff', // Staff login page only accepts staff + }); + + // 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: 'staff', + }, + 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-staff roles - show error and don't authenticate + if (role !== 'staff') { + // 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 staff members. 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 staff + // 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 staff + 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: 'staff', // Staff login page only accepts staff + }; + + 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-staff roles - show error and don't authenticate + if (role !== 'staff') { + // 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 staff members. 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 staff + // 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 staff + 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 ( +
+
+
+ {/* Header */} +
+
+ {settings.company_logo_url ? ( + {settings.company_name + ) : ( +
+ +
+ )} +
+

Staff Login

+

Access your staff dashboard

+
+ +
+ {requiresMFA ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+
+ +
+ +
+ {mfaErrors.mfaToken && ( +

{mfaErrors.mfaToken.message}

+ )} +
+ + + +
+ +
+
+ ) : ( +
+ + + {error && ( +
+ {error} +
+ )} + + {rateLimitInfo && !rateLimitInfo.allowed && ( +
+

Too many login attempts.

+

+ Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()} +

+
+ )} + +
+ +
+
+ +
+ +
+ {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+
+ + +
+
+ +
+ setRecaptchaToken(token)} + onError={(error) => { + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } + setRecaptchaToken(null); + }} + theme="light" + size="normal" + /> +
+ + + + )} + +
+ + ← Back to Home + +
+
+
+
+
+ ); +}; + +export default StaffLoginPage; + diff --git a/Frontend/src/features/auth/pages/index.ts b/Frontend/src/features/auth/pages/index.ts new file mode 100644 index 00000000..b60dd0bf --- /dev/null +++ b/Frontend/src/features/auth/pages/index.ts @@ -0,0 +1,5 @@ +export { default as StaffLoginPage } from './StaffLoginPage'; +export { default as AdminLoginPage } from './AdminLoginPage'; +export { default as HousekeepingLoginPage } from './HousekeepingLoginPage'; +export { default as AccountantLoginPage } from './AccountantLoginPage'; + diff --git a/Frontend/src/features/auth/services/authService.ts b/Frontend/src/features/auth/services/authService.ts index fe3608fa..45b81cdc 100644 --- a/Frontend/src/features/auth/services/authService.ts +++ b/Frontend/src/features/auth/services/authService.ts @@ -5,6 +5,7 @@ export interface LoginCredentials { password: string; rememberMe?: boolean; mfaToken?: string; + expectedRole?: string; // Optional role validation for role-specific login endpoints } export interface RegisterData { @@ -80,7 +81,9 @@ const authService = { try { await apiClient.post('/api/auth/logout'); } catch (error) { - console.error('Logout error:', error); + if (import.meta.env.DEV) { + console.error('Logout error:', error); + } } }, diff --git a/Frontend/src/features/content/pages/AboutPage.tsx b/Frontend/src/features/content/pages/AboutPage.tsx index 978d8ff4..da7e14b0 100644 --- a/Frontend/src/features/content/pages/AboutPage.tsx +++ b/Frontend/src/features/content/pages/AboutPage.tsx @@ -18,10 +18,14 @@ import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer'; const AboutPage: React.FC = () => { const { settings } = useCompanySettings(); const [pageContent, setPageContent] = useState(null); + const [apiError, setApiError] = useState(false); + const [loading, setLoading] = useState(true); useEffect(() => { const fetchPageContent = async () => { try { + setLoading(true); + setApiError(false); const response = await pageContentService.getAboutContent(); if (response.status === 'success' && response.data?.page_content) { setPageContent(response.data.page_content); @@ -39,10 +43,16 @@ const AboutPage: React.FC = () => { } metaDescription.setAttribute('content', response.data.page_content.meta_description); } + } else { + // No data received - don't set error, just leave pageContent as null + setPageContent(null); } } catch (err: any) { console.error('Error fetching page content:', err); - + setApiError(true); + setPageContent(null); + } finally { + setLoading(false); } }; @@ -50,9 +60,10 @@ const AboutPage: React.FC = () => { }, []); - const displayPhone = settings.company_phone || '+1 (234) 567-890'; - const displayEmail = settings.company_email || 'info@luxuryhotel.com'; - const displayAddress = settings.company_address || '123 Luxury Street\nCity, State 12345\nCountry'; + // Only use company settings from API, no hardcoded fallbacks + const displayPhone = settings.company_phone || null; + const displayEmail = settings.company_email || null; + const displayAddress = settings.company_address || null; const defaultValues = [ @@ -96,21 +107,23 @@ const AboutPage: React.FC = () => { } ]; - const values = pageContent?.values && pageContent.values.length > 0 + // Only use default values/features if pageContent was successfully loaded but is empty + // Don't use defaults if API failed + const values = pageContent && pageContent.values && pageContent.values.length > 0 ? pageContent.values.map((v: any) => ({ icon: v.icon || defaultValues.find(d => d.title === v.title)?.icon || 'Heart', title: v.title, description: v.description })) - : defaultValues; + : (pageContent && !apiError ? defaultValues : []); - const features = pageContent?.features && pageContent.features.length > 0 + const features = pageContent && pageContent.features && pageContent.features.length > 0 ? pageContent.features.map((f: any) => ({ icon: f.icon || defaultFeatures.find(d => d.title === f.title)?.icon || 'Star', title: f.title, description: f.description })) - : defaultFeatures; + : (pageContent && !apiError ? defaultFeatures : []); const team = pageContent?.team && typeof pageContent.team === 'string' @@ -130,6 +143,29 @@ const AboutPage: React.FC = () => { return IconComponent; }; + // Show error state if API failed + if (apiError) { + return ( +
+
+

Unable to Load Content

+

Please check your connection and try again later.

+
+
+ ); + } + + // Show loading state + if (loading) { + return ( +
+
+

Loading...

+
+
+ ); + } + return (
{} @@ -235,6 +271,7 @@ const AboutPage: React.FC = () => { {} + {values.length > 0 && (
@@ -280,8 +317,10 @@ const AboutPage: React.FC = () => {
+ )} {} + {features.length > 0 && (
@@ -327,8 +366,8 @@ const AboutPage: React.FC = () => {
+ )} - {} {(pageContent?.mission || pageContent?.vision) && (
@@ -570,7 +609,9 @@ const AboutPage: React.FC = () => { We'd love to hear from you. Contact us for reservations or inquiries.

+ {(displayAddress || displayPhone || displayEmail) && (
+ {displayAddress && (
@@ -588,6 +629,8 @@ const AboutPage: React.FC = () => { ))}

+ )} + {displayPhone && (
@@ -601,6 +644,8 @@ const AboutPage: React.FC = () => {

+ )} + {displayEmail && (
@@ -614,7 +659,9 @@ const AboutPage: React.FC = () => {

+ )}
+ )}
{ const [isLoadingNewest, setIsLoadingNewest] = useState(true); const [, setIsLoadingContent] = useState(true); const [error, setError] = useState(null); + const [apiError, setApiError] = useState(false); + const [apiErrorMessage, setApiErrorMessage] = useState(''); const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const [lightboxImages, setLightboxImages] = useState([]); + // Prevent body scroll when API error modal is shown + useEffect(() => { + if (apiError) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + return () => { + document.body.style.overflow = 'unset'; + }; + }, [apiError]); useEffect(() => { if (!lightboxOpen) return; @@ -86,6 +99,7 @@ const HomePage: React.FC = () => { const fetchPageContent = async () => { try { setIsLoadingContent(true); + setApiError(false); const response = await pageContentService.getHomeContent(); if (response.status === 'success' && response.data?.page_content) { const content = response.data.page_content; @@ -198,11 +212,17 @@ const HomePage: React.FC = () => { document.head.appendChild(metaDescription); } metaDescription.setAttribute('content', content.meta_description); + } else { + setPageContent(null); } + } else { + setPageContent(null); } } catch (err: any) { console.error('Error fetching page content:', err); - + setApiError(true); + setApiErrorMessage('Unable to connect to the server. Please check your internet connection and try again.'); + setPageContent(null); } finally { setIsLoadingContent(false); } @@ -225,11 +245,14 @@ const HomePage: React.FC = () => { response.status === 'success' ) { setBanners(response.data?.banners || []); + } else { + setBanners([]); } } catch (err: any) { console.error('Error fetching banners:', err); - - + setApiError(true); + setApiErrorMessage('Unable to connect to the server. Please check your internet connection and try again.'); + setBanners([]); } finally { setIsLoadingBanners(false); } @@ -261,7 +284,7 @@ const HomePage: React.FC = () => { setError(null); } } else { - + setFeaturedRooms([]); setError( response.message || 'Unable to load room list' @@ -269,7 +292,7 @@ const HomePage: React.FC = () => { } } catch (err: any) { console.error('Error fetching rooms:', err); - + setFeaturedRooms([]); if (err.response?.status === 429) { setError( @@ -307,10 +330,14 @@ const HomePage: React.FC = () => { response.status === 'success' ) { setNewestRooms(response.data?.rooms || []); + } else { + setNewestRooms([]); } } catch (err: any) { console.error('Error fetching newest rooms:', err); - + setApiError(true); + setApiErrorMessage('Unable to connect to the server. Please check your internet connection and try again.'); + setNewestRooms([]); } finally { setIsLoadingNewest(false); } @@ -321,6 +348,60 @@ const HomePage: React.FC = () => { return ( <> + {/* Persistent API Error Modal - Cannot be closed */} + {apiError && ( +
{ + // Prevent closing with Escape key + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + } + }} + onClick={(e) => { + // Prevent closing by clicking outside + e.stopPropagation(); + }} + > +
e.stopPropagation()} + > +
+
+ +
+

+ API Connection Error +

+

+ {apiErrorMessage || 'Unable to connect to the server. The API is currently unavailable.'} +

+
+

+ Please check: +

+
    +
  • Your internet connection
  • +
  • If the server is running
  • +
  • If the API endpoint is accessible
  • +
+
+
+ +
+
+
+
+ )} + {}
{ > {isLoadingBanners ? ( - ) : ( + ) : banners.length > 0 ? (
- )} + ) : null}
@@ -353,10 +434,10 @@ const HomePage: React.FC = () => {

- {pageContent?.hero_title || 'Featured & Newest Rooms'} + {pageContent?.hero_title || (apiError ? '' : 'Featured & Newest Rooms')}

- {pageContent?.hero_subtitle || pageContent?.description || 'Discover our most popular accommodations and latest additions'} + {pageContent?.hero_subtitle || pageContent?.description || (apiError ? '' : 'Discover our most popular accommodations and latest additions')}

{} @@ -439,7 +520,8 @@ const HomePage: React.FC = () => { (f: any) => f && (f.title || f.description) ) || []; - return (validFeatures.length > 0 || !pageContent) && ( + // Only show section if we have features from API, or if pageContent was loaded but is empty (not if API failed) + return validFeatures.length > 0 && (
{} @@ -489,72 +571,7 @@ const HomePage: React.FC = () => { )}
)) - ) : ( - <> -
-
- 🏨 -
-

- Easy Booking -

-

- Search and book rooms with just a few clicks -

-
- -
-
- 💰 -
-

- Best Prices -

-

- Best price guarantee in the market -

-
- -
-
- 🎧 -
-

- 24/7 Support -

-

- Support team always ready to serve -

-
- - )} + ) : null}
diff --git a/Frontend/src/features/rooms/components/BannerCarousel.tsx b/Frontend/src/features/rooms/components/BannerCarousel.tsx index 1caa15c2..ac6e1623 100644 --- a/Frontend/src/features/rooms/components/BannerCarousel.tsx +++ b/Frontend/src/features/rooms/components/BannerCarousel.tsx @@ -53,23 +53,12 @@ const BannerCarousel: React.FC = ({ }; - const defaultBanner: Banner = { - id: 0, - title: 'Welcome to Hotel Booking', - image_url: '/images/default-banner.jpg', - position: 'home', - display_order: 0, - is_active: true, - created_at: '', - updated_at: '', - description: undefined, - link_url: undefined, - }; + // Don't render if no banners - only show banners from API + if (banners.length === 0) { + return null; + } - const displayBanners = banners.length > 0 - ? banners - : [defaultBanner]; - const currentBanner = displayBanners[currentIndex]; + const currentBanner = banners[currentIndex]; return (
= ({ > {}
- {displayBanners.map((banner, index) => ( + {banners.map((banner, index) => (
= ({ src={banner.image_url} alt={banner.title} className="w-full h-full object-cover object-center transition-transform duration-1000 ease-out hover:scale-105" - onError={(e) => { - e.currentTarget.src = '/images/default-banner.jpg'; - }} /> ) : ( @@ -105,9 +91,6 @@ const BannerCarousel: React.FC = ({ src={banner.image_url} alt={banner.title} className="w-full h-full object-cover object-center transition-transform duration-1000 ease-out hover:scale-105" - onError={(e) => { - e.currentTarget.src = '/images/default-banner.jpg'; - }} /> )}
@@ -272,7 +255,7 @@ const BannerCarousel: React.FC = ({
{} - {displayBanners.length > 1 && ( + {banners.length > 1 && ( <> - )} - - {/* Sidebar */} -
-
- {/* Logo/Brand */} -
-
- - Housekeeping +
+ {/* Luxury Top Navigation Bar */} +
+
+
+ {/* Logo/Brand */} +
+
+
+
+ +
+
+
+

+ Enterprise Housekeeping +

+

Luxury Management System

+
-
- {/* Navigation */} - - - {/* User info and logout */} -
-
-

{userInfo?.name || userInfo?.email || 'User'}

-

{userInfo?.role || 'housekeeping'}

+ {/* User Menu */} +
+
+
+ +
+
+

{userInfo?.name || userInfo?.email || 'User'}

+

{userInfo?.role || 'housekeeping'}

+
+
+
-
-
- - {/* Overlay for mobile */} - {isMobile && sidebarOpen && ( -
setSidebarOpen(false)} - /> - )} +
{/* Main content */} -
-
- -
-
+
+ +
); }; diff --git a/Frontend/src/pages/accountant/DashboardPage.tsx b/Frontend/src/pages/accountant/DashboardPage.tsx index 075b2428..b39b4be4 100644 --- a/Frontend/src/pages/accountant/DashboardPage.tsx +++ b/Frontend/src/pages/accountant/DashboardPage.tsx @@ -93,12 +93,29 @@ const AccountantDashboardPage: React.FC = () => { totalPayments: response.data.payments.length, pendingPayments: pendingPayments.length, })); + } else { + // Clear data if response is not successful + setRecentPayments([]); + setFinancialSummary(prev => ({ + ...prev, + totalRevenue: 0, + totalPayments: 0, + pendingPayments: 0, + })); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRecentPayments([]); + setFinancialSummary(prev => ({ + ...prev, + totalRevenue: 0, + totalPayments: 0, + pendingPayments: 0, + })); logger.error('Error fetching payments', err); } finally { setLoadingPayments(false); @@ -139,12 +156,29 @@ const AccountantDashboardPage: React.FC = () => { paidInvoices: paidInvoices.length, overdueInvoices: overdueInvoices.length, })); + } else { + // Clear data if response is not successful + setRecentInvoices([]); + setFinancialSummary(prev => ({ + ...prev, + totalInvoices: 0, + paidInvoices: 0, + overdueInvoices: 0, + })); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRecentInvoices([]); + setFinancialSummary(prev => ({ + ...prev, + totalInvoices: 0, + paidInvoices: 0, + overdueInvoices: 0, + })); logger.error('Error fetching invoices', err); } finally { setLoadingInvoices(false); diff --git a/Frontend/src/pages/admin/AuditLogsPage.tsx b/Frontend/src/pages/admin/AuditLogsPage.tsx index d3484da5..17848e8b 100644 --- a/Frontend/src/pages/admin/AuditLogsPage.tsx +++ b/Frontend/src/pages/admin/AuditLogsPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { FileText, Search, @@ -75,6 +75,10 @@ const AuditLogsPage: React.FC = () => { if (error.name === 'AbortError') { return; } + // Clear data when API connection fails + setLogs([]); + setTotalPages(1); + setTotalItems(0); logger.error('Error fetching audit logs', error); toast.error(error.response?.data?.message || 'Unable to load audit logs'); } finally { diff --git a/Frontend/src/pages/admin/DashboardPage.tsx b/Frontend/src/pages/admin/DashboardPage.tsx index 0578631e..735757d1 100644 --- a/Frontend/src/pages/admin/DashboardPage.tsx +++ b/Frontend/src/pages/admin/DashboardPage.tsx @@ -89,12 +89,17 @@ const DashboardPage: React.FC = () => { const response = await paymentService.getPayments({ page: 1, limit: 5 }); if (response.success && response.data?.payments) { setRecentPayments(response.data.payments); + } else { + // Clear data if response is not successful + setRecentPayments([]); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRecentPayments([]); logger.error('Error fetching payments', err); } finally { setLoadingPayments(false); @@ -125,12 +130,17 @@ const DashboardPage: React.FC = () => { const response = await sessionService.getMySessions(); if (response.success && response.data?.sessions) { setSessions(response.data.sessions || []); + } else { + // Clear data if response is not successful + setSessions([]); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setSessions([]); logger.error('Error fetching sessions', err); } finally { setLoadingSessions(false); diff --git a/Frontend/src/pages/customer/DashboardPage.tsx b/Frontend/src/pages/customer/DashboardPage.tsx index b43c24a0..e9e5128e 100644 --- a/Frontend/src/pages/customer/DashboardPage.tsx +++ b/Frontend/src/pages/customer/DashboardPage.tsx @@ -65,9 +65,14 @@ const DashboardPage: React.FC = () => { const response = await paymentService.getPayments({ page: 1, limit: 5 }); if (response.success && response.data?.payments) { setRecentPayments(response.data.payments); + } else { + // Clear data if response is not successful + setRecentPayments([]); } } catch (err: any) { if (err.name !== 'AbortError') { + // Clear data when API connection fails + setRecentPayments([]); logger.error('Error fetching payments', err); } } finally { @@ -101,9 +106,14 @@ const DashboardPage: React.FC = () => { const response = await sessionService.getMySessions(); if (response.success && response.data?.sessions) { setSessions(response.data.sessions || []); + } else { + // Clear data if response is not successful + setSessions([]); } } catch (err: any) { if (err.name !== 'AbortError') { + // Clear data when API connection fails + setSessions([]); logger.error('Error fetching sessions', err); } } finally { diff --git a/Frontend/src/pages/customer/RoomListPage.tsx b/Frontend/src/pages/customer/RoomListPage.tsx index 101d3b80..d6004531 100644 --- a/Frontend/src/pages/customer/RoomListPage.tsx +++ b/Frontend/src/pages/customer/RoomListPage.tsx @@ -80,6 +80,14 @@ const RoomListPage: React.FC = () => { if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRooms([]); + setPagination({ + total: 0, + page: 1, + limit: 12, + totalPages: 0, + }); logger.error('Error fetching rooms', err); setError('Unable to load room list. Please try again.'); } finally { diff --git a/Frontend/src/pages/housekeeping/DashboardPage.tsx b/Frontend/src/pages/housekeeping/DashboardPage.tsx index f98d5a97..46781eb6 100644 --- a/Frontend/src/pages/housekeeping/DashboardPage.tsx +++ b/Frontend/src/pages/housekeeping/DashboardPage.tsx @@ -8,6 +8,12 @@ import { Calendar, MapPin, Play, + X, + TrendingUp, + Activity, + Award, + Eye, + Building2, ChevronDown, ChevronUp, } from 'lucide-react'; @@ -18,9 +24,310 @@ import advancedRoomService, { HousekeepingTask, ChecklistItem } from '../../feat import { logger } from '../../shared/utils/logger'; import useAuthStore from '../../store/useAuthStore'; +// Luxury Task Detail Modal Component +interface TaskModalProps { + task: HousekeepingTask | null; + isOpen: boolean; + onClose: () => void; + onUpdateChecklist: (task: HousekeepingTask, itemIndex: number, checked: boolean) => Promise; + onStartTask: (task: HousekeepingTask) => Promise; + onCompleteTask: (task: HousekeepingTask) => Promise; + isUpdating: boolean; +} + +const TaskDetailModal: React.FC = ({ + task, + isOpen, + onClose, + onUpdateChecklist, + onStartTask, + onCompleteTask, + isUpdating, +}) => { + if (!isOpen || !task) return null; + + const completedItems = task.checklist_items?.filter(item => item.completed).length || 0; + const totalItems = task.checklist_items?.length || 0; + const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0; + const canStart = task.status === 'pending'; + const canComplete = task.status === 'in_progress' || task.status === 'pending'; + + const getStatusColor = (status: string) => { + switch (status) { + case 'completed': + return 'bg-gradient-to-r from-green-500 to-emerald-600 text-white'; + case 'in_progress': + return 'bg-gradient-to-r from-blue-500 to-cyan-600 text-white'; + case 'pending': + return 'bg-gradient-to-r from-amber-500 to-orange-500 text-white'; + default: + return 'bg-gray-500 text-white'; + } + }; + + return ( +
+ {/* Enhanced Backdrop with luxury effect */} +
+ + {/* Luxury Modal with enhanced styling - Compact */} +
+ {/* Luxury Header with enhanced gradient and effects - Compact */} +
+ {/* Animated background pattern */} +
+
+
+ {/* Shimmer effect */} +
+
+ +
+
+
+
+
+ +
+
+
+

+ Room {task.room_number || task.room_id} +

+
+

{task.task_type}

+ + {task.status.replace('_', ' ').toUpperCase()} + +
+
+
+ +
+
+ + {/* Content with luxury scrollbar - Compact */} +
+ {/* Task Info with luxury cards - Compact */} +
+
+
+
+ +
+
+

Scheduled Time

+

{formatDate(task.scheduled_time)}

+
+
+ {task.completed_at && ( +
+
+
+ +
+
+

Completed At

+

{formatDate(task.completed_at)}

+
+
+ )} +
+ + {/* Notes with luxury styling - Compact */} + {task.notes && ( +
+
+
+

+
+ +
+ Special Notes +

+

{task.notes}

+
+
+ )} + + {/* Progress Bar with luxury styling - Compact */} + {task.checklist_items && task.checklist_items.length > 0 && ( +
+
+

+
+ +
+ Task Progress +

+ {progress}% +
+
+
+
+
+
+

+ {completedItems} of {totalItems} items completed +

+
+ )} + + {/* Checklist with luxury styling - Compact */} + {task.checklist_items && task.checklist_items.length > 0 && ( +
+

+
+ +
+ Checklist Items +

+
+ {task.checklist_items.map((item: ChecklistItem, index: number) => ( + + ))} +
+
+ )} + + {/* Duration with luxury styling - Compact */} + {task.actual_duration_minutes && ( +
+
+

+ Duration: {task.actual_duration_minutes} minutes +

+
+ )} +
+ + {/* Luxury Footer Actions - Sticky and Compact */} +
+
+ + {canStart && ( + + )} + {canComplete && ( + + )} +
+
+
+ ); +}; + +// Utility function to extract floor number from room number +const getFloorFromRoomNumber = (roomNumber: string | number | undefined): number => { + if (!roomNumber) return 0; + const roomStr = String(roomNumber); + // Extract first digit(s) as floor number (e.g., "101" -> 1, "201" -> 2, "1201" -> 12) + const match = roomStr.match(/^(\d+)/); + if (match) { + const floor = parseInt(match[1], 10); + // If room number is like "101", floor is 1; if "201", floor is 2 + // For numbers > 100, take first digit(s) as floor + return floor >= 100 ? Math.floor(floor / 100) : floor; + } + return 0; +}; + +// Get floor display name +const getFloorDisplayName = (floor: number): string => { + if (floor === 0) return 'Ground Floor'; + if (floor === 1) return '1st Floor'; + if (floor === 2) return '2nd Floor'; + if (floor === 3) return '3rd Floor'; + return `${floor}th Floor`; +}; + const HousekeepingDashboardPage: React.FC = () => { const { userInfo } = useAuthStore(); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [tasks, setTasks] = useState([]); const [stats, setStats] = useState({ pending: 0, @@ -28,21 +335,22 @@ const HousekeepingDashboardPage: React.FC = () => { completed: 0, total: 0, }); - const [expandedTasks, setExpandedTasks] = useState>(new Set()); + const [selectedTask, setSelectedTask] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); const [updatingTasks, setUpdatingTasks] = useState>(new Set()); + const [selectedFloor, setSelectedFloor] = useState(null); const tasksAbortRef = useRef(null); const fetchTasks = async () => { try { - // Cancel previous request if exists if (tasksAbortRef.current) { tasksAbortRef.current.abort(); } tasksAbortRef.current = new AbortController(); setLoading(true); + setError(null); - // Fetch today's tasks assigned to current user const today = new Date().toISOString().split('T')[0]; const response = await advancedRoomService.getHousekeepingTasks({ date: today, @@ -56,7 +364,6 @@ const HousekeepingDashboardPage: React.FC = () => { ); setTasks(userTasks); - // Calculate stats const pending = userTasks.filter((t: HousekeepingTask) => t.status === 'pending').length; const in_progress = userTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length; const completed = userTasks.filter((t: HousekeepingTask) => t.status === 'completed').length; @@ -67,13 +374,32 @@ const HousekeepingDashboardPage: React.FC = () => { completed, total: userTasks.length, }); + } else { + setTasks([]); + setStats({ + pending: 0, + in_progress: 0, + completed: 0, + total: 0, + }); } } catch (error: any) { if (error.name === 'AbortError') { return; } logger.error('Error fetching housekeeping tasks', error); - toast.error('Failed to load tasks'); + + setTasks([]); + setStats({ + pending: 0, + in_progress: 0, + completed: 0, + total: 0, + }); + + const errorMessage = error.response?.data?.detail || error.message || 'Failed to connect to server'; + setError(errorMessage); + toast.error('Failed to load tasks. Please check your connection.'); } finally { setLoading(false); } @@ -82,7 +408,6 @@ const HousekeepingDashboardPage: React.FC = () => { useEffect(() => { fetchTasks(); - // Auto-refresh every 30 seconds const interval = setInterval(() => { if (document.visibilityState === 'visible') { fetchTasks(); @@ -100,13 +425,13 @@ const HousekeepingDashboardPage: React.FC = () => { const getStatusColor = (status: string) => { switch (status) { case 'completed': - return 'bg-green-100 text-green-800 border-green-200'; + return 'from-green-500 to-emerald-600'; case 'in_progress': - return 'bg-blue-100 text-blue-800 border-blue-200'; + return 'from-blue-500 to-cyan-600'; case 'pending': - return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + return 'from-amber-500 to-orange-500'; default: - return 'bg-gray-100 text-gray-800 border-gray-200'; + return 'from-gray-500 to-gray-600'; } }; @@ -123,18 +448,70 @@ const HousekeepingDashboardPage: React.FC = () => { } }; - const toggleTaskExpansion = (taskId: number) => { - setExpandedTasks(prev => { - const newSet = new Set(prev); - if (newSet.has(taskId)) { - newSet.delete(taskId); - } else { - newSet.add(taskId); - } - return newSet; - }); + const openTaskModal = (task: HousekeepingTask) => { + setSelectedTask(task); + setIsModalOpen(true); }; + const closeTaskModal = () => { + setIsModalOpen(false); + setSelectedTask(null); + }; + + // Group tasks by floor + const tasksByFloor = React.useMemo(() => { + const grouped: { [floor: number]: HousekeepingTask[] } = {}; + tasks.forEach(task => { + const floor = getFloorFromRoomNumber(task.room_number); + if (!grouped[floor]) { + grouped[floor] = []; + } + grouped[floor].push(task); + }); + + // Sort floors and tasks within each floor + const sortedFloors = Object.keys(grouped) + .map(Number) + .sort((a, b) => a - b); + + const result: { floor: number; tasks: HousekeepingTask[] }[] = []; + sortedFloors.forEach(floor => { + // Sort tasks by room number within each floor + const sortedTasks = grouped[floor].sort((a, b) => { + const roomA = parseInt(String(a.room_number || a.room_id), 10) || 0; + const roomB = parseInt(String(b.room_number || b.room_id), 10) || 0; + return roomA - roomB; + }); + result.push({ floor, tasks: sortedTasks }); + }); + + return result; + }, [tasks]); + + // Auto-select first floor on initial load + useEffect(() => { + if (tasksByFloor.length > 0 && selectedFloor === null) { + setSelectedFloor(tasksByFloor[0].floor); + } + }, [tasksByFloor, selectedFloor]); + + // Get current floor tasks + const currentFloorTasks = React.useMemo(() => { + if (selectedFloor === null) return []; + const floorData = tasksByFloor.find(f => f.floor === selectedFloor); + return floorData?.tasks || []; + }, [tasksByFloor, selectedFloor]); + + // Get current floor stats + const currentFloorStats = React.useMemo(() => { + return { + pending: currentFloorTasks.filter(t => t.status === 'pending').length, + in_progress: currentFloorTasks.filter(t => t.status === 'in_progress').length, + completed: currentFloorTasks.filter(t => t.status === 'completed').length, + total: currentFloorTasks.length, + }; + }, [currentFloorTasks]); + const handleStartTask = async (task: HousekeepingTask) => { if (updatingTasks.has(task.id)) return; @@ -143,8 +520,11 @@ const HousekeepingDashboardPage: React.FC = () => { await advancedRoomService.updateHousekeepingTask(task.id, { status: 'in_progress', }); - toast.success('Task started'); + toast.success('Task started successfully!'); await fetchTasks(); + if (selectedTask?.id === task.id) { + setSelectedTask({ ...task, status: 'in_progress' }); + } } catch (error: any) { logger.error('Error starting task', error); toast.error(error.response?.data?.detail || 'Failed to start task'); @@ -174,7 +554,6 @@ const HousekeepingDashboardPage: React.FC = () => { checklist_items: updatedChecklist, }); - // Update local state immediately for better UX setTasks(prevTasks => prevTasks.map(t => t.id === task.id @@ -183,7 +562,10 @@ const HousekeepingDashboardPage: React.FC = () => { ) ); - // Recalculate stats + if (selectedTask?.id === task.id) { + setSelectedTask({ ...task, checklist_items: updatedChecklist }); + } + const updatedTask = { ...task, checklist_items: updatedChecklist }; const allTasks = tasks.map(t => t.id === task.id ? updatedTask : t); const pending = allTasks.filter((t: HousekeepingTask) => t.status === 'pending').length; @@ -206,7 +588,6 @@ const HousekeepingDashboardPage: React.FC = () => { const handleCompleteTask = async (task: HousekeepingTask) => { if (updatingTasks.has(task.id)) return; - // Check if all checklist items are completed const allCompleted = task.checklist_items?.every(item => item.completed) ?? true; if (!allCompleted && task.checklist_items && task.checklist_items.length > 0) { @@ -218,7 +599,6 @@ const HousekeepingDashboardPage: React.FC = () => { setUpdatingTasks(prev => new Set(prev).add(task.id)); try { - // Mark all checklist items as completed if not already const updatedChecklist = task.checklist_items?.map(item => ({ ...item, completed: true, @@ -228,8 +608,9 @@ const HousekeepingDashboardPage: React.FC = () => { status: 'completed', checklist_items: updatedChecklist, }); - toast.success('Task marked as completed!'); + toast.success('Task completed successfully! 🎉'); await fetchTasks(); + closeTaskModal(); } catch (error: any) { logger.error('Error completing task', error); toast.error(error.response?.data?.detail || 'Failed to complete task'); @@ -242,264 +623,360 @@ const HousekeepingDashboardPage: React.FC = () => { } }; - if (loading && tasks.length === 0) { + if (loading && tasks.length === 0 && !error) { return ; } return ( -
-
-
-

Housekeeping Dashboard

-

- Welcome back, {userInfo?.name || userInfo?.email || 'Housekeeping Staff'} -

+
+
+ {/* Header */} +
+
+

+ Enterprise Dashboard +

+

+ Welcome back, {userInfo?.name || userInfo?.email || 'Housekeeping Staff'} +

+
+
- -
- {/* Stats Cards */} -
-
-
-
-

Pending

-

{stats.pending}

+ {/* Error State */} + {error && ( +
+
+
+
+ +
+
+
+

Connection Error

+

{error}

+

+ Unable to load data from the server. Please check your internet connection and try again. +

+
-
-
+ )} -
-
-
-

In Progress

-

{stats.in_progress}

+ {/* Stats Cards */} + {!error && ( +
+
+
+
+
+
+ +
+ PENDING +
+

Pending Tasks

+

{stats.pending}

+
- -
-
-
-
-
-

Completed

-

{stats.completed}

+
+
+
+
+
+ +
+ IN PROGRESS +
+

In Progress

+

{stats.in_progress}

+
- -
-
-
-
-
-

Total Today

-

{stats.total}

+
+
+
+
+
+ +
+ COMPLETED +
+

Completed

+

{stats.completed}

+
+
+ +
+
+
+
+
+ +
+ TOTAL +
+

Total Today

+

{stats.total}

+
-
-
-
+ )} - {/* Tasks List */} -
-
-

Today's Tasks

-
+ {/* Tasks by Floor - Tab System */} + {!error && ( +
+
+

+ + Today's Tasks by Floor +

+ {tasks.length} task{tasks.length !== 1 ? 's' : ''} across {tasksByFloor.length} floor{tasksByFloor.length !== 1 ? 's' : ''} +
- {tasks.length === 0 ? ( -
- -

No tasks assigned

-

You don't have any housekeeping tasks assigned for today.

-
- ) : ( -
- {tasks.map((task) => { - const completedItems = task.checklist_items?.filter(item => item.completed).length || 0; - const totalItems = task.checklist_items?.length || 0; - const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0; + {tasks.length === 0 ? ( +
+
+ +
+

No tasks assigned

+

You don't have any housekeeping tasks assigned for today.

+
+ ) : ( +
+ {/* Floor Tabs */} +
+
+ {tasksByFloor.map(({ floor, tasks: floorTasks }) => { + const isActive = selectedFloor === floor; + const floorStats = { + pending: floorTasks.filter(t => t.status === 'pending').length, + in_progress: floorTasks.filter(t => t.status === 'in_progress').length, + completed: floorTasks.filter(t => t.status === 'completed').length, + total: floorTasks.length, + }; - const isExpanded = expandedTasks.has(task.id); - const isUpdating = updatingTasks.has(task.id); - const canStart = task.status === 'pending'; - const canComplete = task.status === 'in_progress' || task.status === 'pending'; - - return ( -
-
-
-
-
-

- Room {task.room_number || task.room_id} -

- - {getStatusIcon(task.status)} - {task.status.replace('_', ' ')} - -
- -
-
- - {formatDate(task.scheduled_time)} -
-
- - {task.task_type} -
-
- - {task.checklist_items && task.checklist_items.length > 0 && ( -
-
- Progress - {progress}% -
-
-
-
-

- {completedItems} of {totalItems} items completed -

-
- )} -
- -
- {canStart && ( - - )} - {canComplete && ( - - )} + return ( -
-
+ ); + })}
+
- {/* Expanded Task Details */} - {isExpanded && ( -
-
- {task.notes && ( -
-

Notes

-

{task.notes}

+ {/* Current Floor Tasks */} + {selectedFloor !== null && ( +
+ {/* Floor Header with Stats */} +
+
+
+
+
- )} - - {task.checklist_items && task.checklist_items.length > 0 && (
-

Checklist

-
- {task.checklist_items.map((item: ChecklistItem, index: number) => ( - - ))} -
-
- )} - - {task.status === 'completed' && task.completed_at && ( -
-

- Completed: {formatDate(task.completed_at)} +

+ {getFloorDisplayName(selectedFloor)} +

+

+ {currentFloorStats.total} room{currentFloorStats.total !== 1 ? 's' : ''} assigned

- {task.actual_duration_minutes && ( -

- Duration: {task.actual_duration_minutes} minutes -

- )}
- )} +
+
+ {currentFloorStats.pending > 0 && ( +
+ + {currentFloorStats.pending} Pending +
+ )} + {currentFloorStats.in_progress > 0 && ( +
+ + {currentFloorStats.in_progress} In Progress +
+ )} + {currentFloorStats.completed > 0 && ( +
+ + {currentFloorStats.completed} Completed +
+ )} +
- )} -
- ); - })} + + {/* Tasks Grid */} + {currentFloorTasks.length === 0 ? ( +
+
+ +
+

No tasks on this floor

+

No housekeeping tasks assigned for {getFloorDisplayName(selectedFloor).toLowerCase()}.

+
+ ) : ( +
+ {currentFloorTasks.map((task) => { + const completedItems = task.checklist_items?.filter(item => item.completed).length || 0; + const totalItems = task.checklist_items?.length || 0; + const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0; + const isUpdating = updatingTasks.has(task.id); + const canStart = task.status === 'pending'; + const canComplete = task.status === 'in_progress' || task.status === 'pending'; + + return ( +
openTaskModal(task)} + > +
+
+
+
+ +
+
+

+ Room {task.room_number || task.room_id} +

+

{task.task_type}

+
+
+
+ + {task.status.replace('_', ' ')} + +
+ +
+
+ + {formatDate(task.scheduled_time)} +
+
+ + {task.checklist_items && task.checklist_items.length > 0 && ( +
+
+ Progress + {progress}% +
+
+
+
+

+ {completedItems} of {totalItems} items completed +

+
+ )} + +
+ +
+ {canStart && ( + + )} + {canComplete && ( + + )} +
+
+
+ ); + })} +
+ )} +
+ )} +
+ )}
)}
+ + {/* Task Detail Modal */} +
); }; export default HousekeepingDashboardPage; - diff --git a/Frontend/src/pages/staff/ChatManagementPage.tsx b/Frontend/src/pages/staff/ChatManagementPage.tsx index 44b60e7b..e15ffe6c 100644 --- a/Frontend/src/pages/staff/ChatManagementPage.tsx +++ b/Frontend/src/pages/staff/ChatManagementPage.tsx @@ -218,8 +218,13 @@ const ChatManagementPage: React.FC = () => { const response = await chatService.listChats(); if (response.success) { setChats(response.data); + } else { + // Clear data if response is not successful + setChats([]); } } catch (error: any) { + // Clear data when API connection fails + setChats([]); toast.error(error.response?.data?.detail || 'Failed to load chats'); } finally { setLoading(false); @@ -232,8 +237,13 @@ const ChatManagementPage: React.FC = () => { const response = await chatService.getMessages(chatId); if (response.success) { setMessages(response.data); + } else { + // Clear data if response is not successful + setMessages([]); } } catch (error: any) { + // Clear data when API connection fails + setMessages([]); toast.error(error.response?.data?.detail || 'Failed to load messages'); } finally { setLoadingMessages(false); diff --git a/Frontend/src/pages/staff/DashboardPage.tsx b/Frontend/src/pages/staff/DashboardPage.tsx index ba544b45..cb7d2ad7 100644 --- a/Frontend/src/pages/staff/DashboardPage.tsx +++ b/Frontend/src/pages/staff/DashboardPage.tsx @@ -73,12 +73,17 @@ const StaffDashboardPage: React.FC = () => { const response = await paymentService.getPayments({ page: 1, limit: 5 }); if (response.success && response.data?.payments) { setRecentPayments(response.data.payments); + } else { + // Clear data if response is not successful + setRecentPayments([]); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRecentPayments([]); logger.error('Error fetching payments', err); } finally { setLoadingPayments(false); @@ -109,12 +114,17 @@ const StaffDashboardPage: React.FC = () => { const response = await bookingService.getAllBookings({ page: 1, limit: 5 }); if ((response.status === 'success' || response.success) && response.data?.bookings) { setRecentBookings(response.data.bookings); + } else { + // Clear data if response is not successful + setRecentBookings([]); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRecentBookings([]); logger.error('Error fetching bookings', err); } finally { setLoadingBookings(false); diff --git a/Frontend/src/shared/components/Footer.tsx b/Frontend/src/shared/components/Footer.tsx index b152bc92..f7d8a97f 100644 --- a/Frontend/src/shared/components/Footer.tsx +++ b/Frontend/src/shared/components/Footer.tsx @@ -34,16 +34,22 @@ const Footer: React.FC = () => { const { settings } = useCompanySettings(); const [pageContent, setPageContent] = useState(null); const [enabledPages, setEnabledPages] = useState>(new Set()); + const [apiError, setApiError] = useState(false); useEffect(() => { const fetchPageContent = async () => { try { + setApiError(false); const response = await pageContentService.getFooterContent(); if (response.status === 'success' && response.data?.page_content) { setPageContent(response.data.page_content); + } else { + setPageContent(null); } } catch (err: any) { console.error('Error fetching footer content:', err); + setApiError(true); + setPageContent(null); } }; @@ -82,9 +88,10 @@ const Footer: React.FC = () => { }, []); + // Only use company settings from API, no hardcoded fallbacks const displayPhone = settings.company_phone || null; const displayEmail = settings.company_email || null; - const displayAddress = settings.company_address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam'; + const displayAddress = settings.company_address || null; const phoneNumber = displayPhone ? displayPhone.replace(/\s+/g, '').replace(/[()]/g, '') : ''; const phoneHref = displayPhone ? 'tel:' + phoneNumber : ''; @@ -134,13 +141,15 @@ const Footer: React.FC = () => { { label: 'Contact Us', url: '/contact' } ]; + // Only use default links if pageContent was successfully loaded but is empty + // Don't use defaults if API failed const quickLinks = pageContent?.footer_links?.quick_links && pageContent.footer_links.quick_links.length > 0 ? pageContent.footer_links.quick_links - : defaultQuickLinks; + : (pageContent && !apiError ? defaultQuickLinks : []); const allSupportLinks = pageContent?.footer_links?.support_links && pageContent.footer_links.support_links.length > 0 ? pageContent.footer_links.support_links - : defaultSupportLinks; + : (pageContent && !apiError ? defaultSupportLinks : []); // Filter support links to only show enabled policy pages const supportLinks = allSupportLinks.filter((link) => { @@ -284,6 +293,7 @@ const Footer: React.FC = () => {
{/* Quick Links */} + {quickLinks.length > 0 && (

Quick Links @@ -303,8 +313,10 @@ const Footer: React.FC = () => { ))}

+ )} {/* Guest Services */} + {supportLinks.length > 0 && (

Guest Services @@ -324,6 +336,7 @@ const Footer: React.FC = () => { ))}

+ )} {/* Contact Information */}
@@ -331,7 +344,9 @@ const Footer: React.FC = () => { Contact + {(displayAddress || displayPhone || displayEmail) && (
    + {displayAddress && (
  • @@ -340,15 +355,16 @@ const Footer: React.FC = () => {
    - {(displayAddress + {displayAddress .split('\n').map((line, i) => ( {line} {i < displayAddress.split('\n').length - 1 &&
    }
    - )))} + ))}
  • + )} {displayPhone && (
  • @@ -376,6 +392,7 @@ const Footer: React.FC = () => {
  • )}
+ )} {/* Rating Section */}
diff --git a/Frontend/src/styles/index.css b/Frontend/src/styles/index.css index 42b7cabe..d2255550 100644 --- a/Frontend/src/styles/index.css +++ b/Frontend/src/styles/index.css @@ -587,3 +587,23 @@ img[loading="lazy"]:not([src]) { .prose.prose-invert a { color: #d4af37 !important; } + +/* Custom luxury scrollbar for modals */ +.custom-scrollbar::-webkit-scrollbar { + width: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: #f3f4f6; + border-radius: 10px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #d4af37 0%, #c9a227 100%); + border-radius: 10px; + border: 2px solid #f3f4f6; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, #f5d76e 0%, #d4af37 100%); +}