This commit is contained in:
Iliyan Angelov
2025-12-01 23:30:28 +02:00
parent f7d6f24e49
commit 86e78247c3
38 changed files with 3765 additions and 547 deletions

View File

@@ -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(

View File

@@ -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

View File

@@ -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,

View File

@@ -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(

View File

@@ -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() {
/>
</Route>
{/* Separate Login Pages for Each Role */}
<Route
path="/staff/login"
element={
<ErrorBoundaryRoute>
<StaffLoginPage />
</ErrorBoundaryRoute>
}
/>
<Route
path="/admin/login"
element={
<ErrorBoundaryRoute>
<AdminLoginPage />
</ErrorBoundaryRoute>
}
/>
<Route
path="/housekeeping/login"
element={
<ErrorBoundaryRoute>
<HousekeepingLoginPage />
</ErrorBoundaryRoute>
}
/>
<Route
path="/accountant/login"
element={
<ErrorBoundaryRoute>
<AccountantLoginPage />
</ErrorBoundaryRoute>
}
/>
{}
<Route
path="/reset-password/:token"

View File

@@ -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 AccountantRouteProps {
children: React.ReactNode;
@@ -11,14 +10,6 @@ const AccountantRoute: React.FC<AccountantRouteProps> = ({
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<AccountantRouteProps> = ({
);
}
// 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 <Navigate to="/accountant/login" replace />;
}
// Check if user is accountant

View File

@@ -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<AdminRouteProps> = ({
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<AdminRouteProps> = ({
if (!isAuthenticated) {
return null; // Modal will be shown by AuthModalManager
return <Navigate to="/admin/login" replace />;
}

View File

@@ -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);
}
}
};

View File

@@ -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<HousekeepingRouteProps> = ({ 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<HousekeepingRouteProps> = ({ children }) => {
}
if (!isAuthenticated) {
return null; // Modal will be shown by AuthModalManager
return <Navigate to="/housekeeping/login" replace />;
}
// Only allow housekeeping role - no admin or staff access

View File

@@ -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<typeof mfaTokenSchema>;
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 = () => {
<Recaptcha
onChange={(token) => setRecaptchaToken(token)}
onError={(error) => {
console.error('reCAPTCHA error:', error);
if (import.meta.env.DEV) {
console.error('reCAPTCHA error:', error);
}
setRecaptchaToken(null);
}}
theme="light"

View File

@@ -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 = () => {
<Recaptcha
onChange={(token) => setRecaptchaToken(token)}
onError={(error) => {
console.error('reCAPTCHA error:', error);
if (import.meta.env.DEV) {
console.error('reCAPTCHA error:', error);
}
setRecaptchaToken(null);
}}
theme="light"

View File

@@ -95,7 +95,9 @@ const ResetPasswordModal: React.FC<ResetPasswordModalProps> = ({ token }) => {
});
setIsSuccess(true);
} catch (error) {
console.error('Reset password error:', error);
if (import.meta.env.DEV) {
console.error('Reset password error:', error);
}
}
};

View File

@@ -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<StaffRouteProps> = ({
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<StaffRouteProps> = ({
if (!isAuthenticated) {
return null; // Modal will be shown by AuthModalManager
return <Navigate to="/staff/login" replace />;
}

View File

@@ -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;
}

View File

@@ -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<typeof mfaTokenSchema>;
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<MFATokenFormData>({
resolver: yupResolver(mfaTokenSchema),
defaultValues: {
mfaToken: '',
},
});
useEffect(() => {
if (!isLoading && isAuthenticated && !requiresMFA && userInfo) {
const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase();
// Safety check - should not happen if onSubmit logic works correctly
if (role !== 'accountant') {
navigate(`/${role}/login`, { replace: true });
return;
}
navigate('/accountant/dashboard', { replace: true });
}
}, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: yupResolver(loginSchema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
const onSubmit = async (data: LoginFormData) => {
try {
clearError();
// Validate antibot protection
const isValid = await validateAntibot();
if (!isValid) {
return;
}
// Verify reCAPTCHA if token is provided
if (recaptchaToken) {
try {
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
toast.error('reCAPTCHA verification failed. Please try again.');
setRecaptchaToken(null);
return;
}
} catch (error) {
toast.error('reCAPTCHA verification failed. Please try again.');
setRecaptchaToken(null);
return;
}
}
// Call login API directly to check role BEFORE showing success toast
useAuthStore.setState({ isLoading: true, error: null });
try {
const response = await authService.login({
email: data.email,
password: data.password,
rememberMe: data.rememberMe,
expectedRole: '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 (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-2xl border border-gray-200/50 overflow-hidden">
<div className="bg-gradient-to-r from-green-600 via-emerald-500 to-teal-600 p-6 sm:p-8 text-center">
<div className="flex justify-center mb-4">
{settings.company_logo_url ? (
<img
src={settings.company_logo_url.startsWith('http')
? settings.company_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`}
alt={settings.company_name || 'Logo'}
className="h-16 w-auto max-w-[150px] object-contain bg-white/20 p-2 rounded-lg"
/>
) : (
<div className="relative p-3 bg-white/20 backdrop-blur-sm rounded-full">
<Calculator className="w-10 h-10 text-white" />
</div>
)}
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-2">Accountant Login</h1>
<p className="text-green-100 text-sm">Access your accountant dashboard</p>
</div>
<div className="p-6 sm:p-8">
{requiresMFA ? (
<form onSubmit={handleSubmitMFA(onSubmitMFA)} className="space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="mfaToken" className="block text-sm font-medium text-gray-700 mb-2">
Authentication Code
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Shield className="h-5 w-5 text-gray-400" />
</div>
<input
{...registerMFA('mfaToken')}
id="mfaToken"
type="text"
autoComplete="one-time-code"
maxLength={8}
className={`w-full pl-10 pr-4 py-3 border rounded-lg text-center tracking-widest ${
mfaErrors.mfaToken ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-green-500'
}`}
placeholder="000000"
/>
</div>
{mfaErrors.mfaToken && (
<p className="mt-1 text-sm text-red-600">{mfaErrors.mfaToken.message}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-green-600 to-teal-600 text-white py-3 px-4 rounded-lg font-semibold hover:from-green-700 hover:to-teal-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<Loader2 className="animate-spin mr-2 h-5 w-5" />
Verifying...
</>
) : (
<>
<Shield className="mr-2 h-5 w-5" />
Verify
</>
)}
</button>
<div className="text-center">
<button
type="button"
onClick={handleBackToLogin}
className="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Login
</button>
</div>
</form>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<HoneypotField value={honeypotValue} onChange={setHoneypotValue} />
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
{rateLimitInfo && !rateLimitInfo.allowed && (
<div className="bg-yellow-50 border border-yellow-200 text-yellow-700 px-4 py-3 rounded-lg text-sm">
<p className="font-medium">Too many login attempts.</p>
<p className="text-xs mt-1">
Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()}
</p>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('email')}
id="email"
type="email"
autoComplete="email"
className={`w-full pl-10 pr-4 py-3 border rounded-lg ${
errors.email ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-green-500'
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('password')}
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
className={`w-full pl-10 pr-10 py-3 border rounded-lg ${
errors.password ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-green-500'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div className="flex items-center">
<input
{...register('rememberMe')}
id="rememberMe"
type="checkbox"
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded"
/>
<label htmlFor="rememberMe" className="ml-2 block text-sm text-gray-700">
Remember me
</label>
</div>
<div className="flex justify-center">
<Recaptcha
onChange={(token) => setRecaptchaToken(token)}
onError={(error) => {
if (import.meta.env.DEV) {
console.error('reCAPTCHA error:', error);
}
setRecaptchaToken(null);
}}
theme="light"
size="normal"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-green-600 to-teal-600 text-white py-3 px-4 rounded-lg font-semibold hover:from-green-700 hover:to-teal-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<Loader2 className="animate-spin mr-2 h-5 w-5" />
Processing...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
Sign In
</>
)}
</button>
</form>
)}
<div className="mt-6 text-center">
<Link
to="/"
className="text-sm text-gray-600 hover:text-gray-900"
>
Back to Home
</Link>
</div>
</div>
</div>
</div>
</div>
);
};
export default AccountantLoginPage;

View File

@@ -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<typeof mfaTokenSchema>;
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<MFATokenFormData>({
resolver: yupResolver(mfaTokenSchema),
defaultValues: {
mfaToken: '',
},
});
useEffect(() => {
if (!isLoading && isAuthenticated && !requiresMFA && userInfo) {
const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase();
// Safety check - should not happen if onSubmit logic works correctly
if (role !== 'admin') {
navigate(`/${role}/login`, { replace: true });
return;
}
navigate('/admin/dashboard', { replace: true });
}
}, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: yupResolver(loginSchema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
const onSubmit = async (data: LoginFormData) => {
try {
clearError();
// Validate antibot protection
const isValid = await validateAntibot();
if (!isValid) {
return;
}
// Verify reCAPTCHA if token is provided
if (recaptchaToken) {
try {
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
toast.error('reCAPTCHA verification failed. Please try again.');
setRecaptchaToken(null);
return;
}
} catch (error) {
toast.error('reCAPTCHA verification failed. Please try again.');
setRecaptchaToken(null);
return;
}
}
// Call login API directly to check role BEFORE showing success toast
useAuthStore.setState({ isLoading: true, error: null });
try {
const response = await authService.login({
email: data.email,
password: data.password,
rememberMe: data.rememberMe,
expectedRole: '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 (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-2xl border border-gray-200/50 overflow-hidden">
<div className="bg-gradient-to-r from-purple-600 via-purple-500 to-indigo-600 p-6 sm:p-8 text-center">
<div className="flex justify-center mb-4">
{settings.company_logo_url ? (
<img
src={settings.company_logo_url.startsWith('http')
? settings.company_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`}
alt={settings.company_name || 'Logo'}
className="h-16 w-auto max-w-[150px] object-contain bg-white/20 p-2 rounded-lg"
/>
) : (
<div className="relative p-3 bg-white/20 backdrop-blur-sm rounded-full">
<Settings className="w-10 h-10 text-white" />
</div>
)}
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-2">Admin Login</h1>
<p className="text-purple-100 text-sm">Access your admin dashboard</p>
</div>
<div className="p-6 sm:p-8">
{requiresMFA ? (
<form onSubmit={handleSubmitMFA(onSubmitMFA)} className="space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="mfaToken" className="block text-sm font-medium text-gray-700 mb-2">
Authentication Code
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Shield className="h-5 w-5 text-gray-400" />
</div>
<input
{...registerMFA('mfaToken')}
id="mfaToken"
type="text"
autoComplete="one-time-code"
maxLength={8}
className={`w-full pl-10 pr-4 py-3 border rounded-lg text-center tracking-widest ${
mfaErrors.mfaToken ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-purple-500'
}`}
placeholder="000000"
/>
</div>
{mfaErrors.mfaToken && (
<p className="mt-1 text-sm text-red-600">{mfaErrors.mfaToken.message}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 text-white py-3 px-4 rounded-lg font-semibold hover:from-purple-700 hover:to-indigo-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<Loader2 className="animate-spin mr-2 h-5 w-5" />
Verifying...
</>
) : (
<>
<Shield className="mr-2 h-5 w-5" />
Verify
</>
)}
</button>
<div className="text-center">
<button
type="button"
onClick={handleBackToLogin}
className="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Login
</button>
</div>
</form>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<HoneypotField value={honeypotValue} onChange={setHoneypotValue} />
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
{rateLimitInfo && !rateLimitInfo.allowed && (
<div className="bg-yellow-50 border border-yellow-200 text-yellow-700 px-4 py-3 rounded-lg text-sm">
<p className="font-medium">Too many login attempts.</p>
<p className="text-xs mt-1">
Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()}
</p>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('email')}
id="email"
type="email"
autoComplete="email"
className={`w-full pl-10 pr-4 py-3 border rounded-lg ${
errors.email ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-purple-500'
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('password')}
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
className={`w-full pl-10 pr-10 py-3 border rounded-lg ${
errors.password ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-purple-500'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div className="flex items-center">
<input
{...register('rememberMe')}
id="rememberMe"
type="checkbox"
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
/>
<label htmlFor="rememberMe" className="ml-2 block text-sm text-gray-700">
Remember me
</label>
</div>
<div className="flex justify-center">
<Recaptcha
onChange={(token) => setRecaptchaToken(token)}
onError={(error) => {
if (import.meta.env.DEV) {
console.error('reCAPTCHA error:', error);
}
setRecaptchaToken(null);
}}
theme="light"
size="normal"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 text-white py-3 px-4 rounded-lg font-semibold hover:from-purple-700 hover:to-indigo-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<Loader2 className="animate-spin mr-2 h-5 w-5" />
Processing...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
Sign In
</>
)}
</button>
</form>
)}
<div className="mt-6 text-center">
<Link
to="/"
className="text-sm text-gray-600 hover:text-gray-900"
>
Back to Home
</Link>
</div>
</div>
</div>
</div>
</div>
);
};
export default AdminLoginPage;

View File

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

View File

@@ -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<typeof mfaTokenSchema>;
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<MFATokenFormData>({
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<LoginFormData>({
resolver: yupResolver(loginSchema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
const onSubmit = async (data: LoginFormData) => {
try {
clearError();
// Validate antibot protection
const isValid = await validateAntibot();
if (!isValid) {
return;
}
// Verify reCAPTCHA if token is provided
if (recaptchaToken) {
try {
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
toast.error('reCAPTCHA verification failed. Please try again.');
setRecaptchaToken(null);
return;
}
} catch (error) {
toast.error('reCAPTCHA verification failed. Please try again.');
setRecaptchaToken(null);
return;
}
}
// Call login API directly to check role BEFORE showing success toast
useAuthStore.setState({ isLoading: true, error: null });
try {
const response = await authService.login({
email: data.email,
password: data.password,
rememberMe: data.rememberMe,
expectedRole: '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 (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-2xl border border-gray-200/50 overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 via-blue-500 to-cyan-600 p-6 sm:p-8 text-center">
<div className="flex justify-center mb-4">
{settings.company_logo_url ? (
<img
src={settings.company_logo_url.startsWith('http')
? settings.company_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`}
alt={settings.company_name || 'Logo'}
className="h-16 w-auto max-w-[150px] object-contain bg-white/20 p-2 rounded-lg"
/>
) : (
<div className="relative p-3 bg-white/20 backdrop-blur-sm rounded-full">
<User className="w-10 h-10 text-white" />
</div>
)}
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white mb-2">Staff Login</h1>
<p className="text-blue-100 text-sm">Access your staff dashboard</p>
</div>
<div className="p-6 sm:p-8">
{requiresMFA ? (
<form onSubmit={handleSubmitMFA(onSubmitMFA)} className="space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="mfaToken" className="block text-sm font-medium text-gray-700 mb-2">
Authentication Code
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Shield className="h-5 w-5 text-gray-400" />
</div>
<input
{...registerMFA('mfaToken')}
id="mfaToken"
type="text"
autoComplete="one-time-code"
maxLength={8}
className={`w-full pl-10 pr-4 py-3 border rounded-lg text-center tracking-widest ${
mfaErrors.mfaToken ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="000000"
/>
</div>
{mfaErrors.mfaToken && (
<p className="mt-1 text-sm text-red-600">{mfaErrors.mfaToken.message}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-blue-600 to-cyan-600 text-white py-3 px-4 rounded-lg font-semibold hover:from-blue-700 hover:to-cyan-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<Loader2 className="animate-spin mr-2 h-5 w-5" />
Verifying...
</>
) : (
<>
<Shield className="mr-2 h-5 w-5" />
Verify
</>
)}
</button>
<div className="text-center">
<button
type="button"
onClick={handleBackToLogin}
className="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Login
</button>
</div>
</form>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<HoneypotField value={honeypotValue} onChange={setHoneypotValue} />
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
{rateLimitInfo && !rateLimitInfo.allowed && (
<div className="bg-yellow-50 border border-yellow-200 text-yellow-700 px-4 py-3 rounded-lg text-sm">
<p className="font-medium">Too many login attempts.</p>
<p className="text-xs mt-1">
Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()}
</p>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('email')}
id="email"
type="email"
autoComplete="email"
className={`w-full pl-10 pr-4 py-3 border rounded-lg ${
errors.email ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="email@example.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
{...register('password')}
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
className={`w-full pl-10 pr-10 py-3 border rounded-lg ${
errors.password ? 'border-red-300 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
{...register('rememberMe')}
id="rememberMe"
type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="rememberMe" className="ml-2 block text-sm text-gray-700">
Remember me
</label>
</div>
</div>
<div className="flex justify-center">
<Recaptcha
onChange={(token) => setRecaptchaToken(token)}
onError={(error) => {
if (import.meta.env.DEV) {
console.error('reCAPTCHA error:', error);
}
setRecaptchaToken(null);
}}
theme="light"
size="normal"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-blue-600 to-cyan-600 text-white py-3 px-4 rounded-lg font-semibold hover:from-blue-700 hover:to-cyan-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<Loader2 className="animate-spin mr-2 h-5 w-5" />
Processing...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
Sign In
</>
)}
</button>
</form>
)}
<div className="mt-6 text-center">
<Link
to="/"
className="text-sm text-gray-600 hover:text-gray-900"
>
Back to Home
</Link>
</div>
</div>
</div>
</div>
</div>
);
};
export default StaffLoginPage;

View File

@@ -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';

View File

@@ -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);
}
}
},

View File

@@ -18,10 +18,14 @@ import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
const AboutPage: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(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 (
<div className="min-h-screen bg-gradient-to-b from-slate-50 via-white to-slate-50 flex items-center justify-center">
<div className="text-center p-8">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Unable to Load Content</h1>
<p className="text-gray-600">Please check your connection and try again later.</p>
</div>
</div>
);
}
// Show loading state
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 via-white to-slate-50 flex items-center justify-center">
<div className="text-center p-8">
<p className="text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 via-white to-slate-50">
{}
@@ -235,6 +271,7 @@ const AboutPage: React.FC = () => {
</section>
{}
{values.length > 0 && (
<section className="py-20 md:py-28 bg-gradient-to-b from-slate-50 via-white to-slate-50 relative">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(212,175,55,0.03),transparent_50%)]"></div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
@@ -280,8 +317,10 @@ const AboutPage: React.FC = () => {
</div>
</div>
</section>
)}
{}
{features.length > 0 && (
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(135deg,transparent_0%,rgba(212,175,55,0.02)_50%,transparent_100%)]"></div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
@@ -327,8 +366,8 @@ const AboutPage: React.FC = () => {
</div>
</div>
</section>
)}
{}
{(pageContent?.mission || pageContent?.vision) && (
<section className="py-20 md:py-28 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 relative overflow-hidden">
<div className="absolute inset-0">
@@ -570,7 +609,9 @@ const AboutPage: React.FC = () => {
We'd love to hear from you. Contact us for reservations or inquiries.
</p>
</div>
{(displayAddress || displayPhone || displayEmail) && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-10 mb-16">
{displayAddress && (
<div className="group text-center p-8 bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2">
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
<MapPin className="w-8 h-8 text-white drop-shadow-md" />
@@ -588,6 +629,8 @@ const AboutPage: React.FC = () => {
))}
</p>
</div>
)}
{displayPhone && (
<div className="group text-center p-8 bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2" style={{ animationDelay: '0.1s' }}>
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
<Phone className="w-8 h-8 text-white drop-shadow-md" />
@@ -601,6 +644,8 @@ const AboutPage: React.FC = () => {
</a>
</p>
</div>
)}
{displayEmail && (
<div className="group text-center p-8 bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2" style={{ animationDelay: '0.2s' }}>
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
<Mail className="w-8 h-8 text-white drop-shadow-md" />
@@ -614,7 +659,9 @@ const AboutPage: React.FC = () => {
</a>
</p>
</div>
)}
</div>
)}
<div className="text-center">
<Link
to="/rooms"

View File

@@ -34,10 +34,23 @@ const HomePage: React.FC = () => {
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
const [, setIsLoadingContent] = useState(true);
const [error, setError] = useState<string | null>(null);
const [apiError, setApiError] = useState(false);
const [apiErrorMessage, setApiErrorMessage] = useState<string>('');
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
// 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 && (
<div
className="fixed inset-0 z-[99999] bg-black/90 backdrop-blur-sm flex items-center justify-center p-4"
style={{ pointerEvents: 'auto' }}
onKeyDown={(e) => {
// Prevent closing with Escape key
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
}
}}
onClick={(e) => {
// Prevent closing by clicking outside
e.stopPropagation();
}}
>
<div
className="bg-white rounded-xl shadow-2xl max-w-md w-full p-8 border-2 border-red-500"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col items-center text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
<AlertCircle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">
API Connection Error
</h2>
<p className="text-gray-600 mb-6 leading-relaxed">
{apiErrorMessage || 'Unable to connect to the server. The API is currently unavailable.'}
</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 w-full">
<p className="text-sm text-red-800 font-medium">
Please check:
</p>
<ul className="text-sm text-red-700 mt-2 text-left list-disc list-inside space-y-1">
<li>Your internet connection</li>
<li>If the server is running</li>
<li>If the API endpoint is accessible</li>
</ul>
</div>
<div className="mt-6 w-full">
<button
onClick={() => window.location.reload()}
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
Refresh Page
</button>
</div>
</div>
</div>
</div>
)}
{}
<section
className="relative w-screen -mt-6"
@@ -331,13 +412,13 @@ const HomePage: React.FC = () => {
>
{isLoadingBanners ? (
<BannerSkeleton />
) : (
) : banners.length > 0 ? (
<div className="animate-fade-in">
<BannerCarousel banners={banners}>
<SearchRoomForm className="overlay" />
</BannerCarousel>
</div>
)}
) : null}
</section>
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50/30 relative overflow-hidden">
@@ -353,10 +434,10 @@ const HomePage: React.FC = () => {
<div className="h-0.5 w-16 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4">
{pageContent?.hero_title || 'Featured & Newest Rooms'}
{pageContent?.hero_title || (apiError ? '' : 'Featured & Newest Rooms')}
</h2>
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto px-4 leading-relaxed">
{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')}
</p>
{}
@@ -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 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="relative bg-white rounded-xl md:rounded-2xl shadow-xl shadow-[#d4af37]/5 p-6 md:p-8 lg:p-10 animate-fade-in overflow-hidden border border-gray-100/50">
{}
@@ -489,72 +571,7 @@ const HomePage: React.FC = () => {
)}
</div>
))
) : (
<>
<div className="text-center group relative">
<div
className="w-16 h-16 md:w-18 md:h-18 bg-gradient-to-br from-[#d4af37]/15 via-[#f5d76e]/10 to-[#d4af37]/15
rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-5
shadow-lg shadow-[#d4af37]/15 border border-[#d4af37]/25
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30 group-hover:border-[#d4af37]/40
transition-all duration-300 backdrop-blur-sm"
>
<span className="text-3xl">🏨</span>
</div>
<h3
className="text-lg md:text-xl font-serif font-semibold mb-2 md:mb-3
text-gray-900 group-hover:text-[#d4af37] transition-colors duration-300 tracking-tight leading-tight"
>
Easy Booking
</h3>
<p className="text-sm md:text-base text-gray-600 leading-relaxed font-light tracking-wide max-w-xs mx-auto">
Search and book rooms with just a few clicks
</p>
</div>
<div className="text-center group relative">
<div
className="w-16 h-16 md:w-18 md:h-18 bg-gradient-to-br from-[#d4af37]/15 via-[#f5d76e]/10 to-[#d4af37]/15
rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-5
shadow-lg shadow-[#d4af37]/15 border border-[#d4af37]/25
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30 group-hover:border-[#d4af37]/40
transition-all duration-300 backdrop-blur-sm"
>
<span className="text-3xl">💰</span>
</div>
<h3
className="text-lg md:text-xl font-serif font-semibold mb-2 md:mb-3
text-gray-900 group-hover:text-[#d4af37] transition-colors duration-300 tracking-tight leading-tight"
>
Best Prices
</h3>
<p className="text-sm md:text-base text-gray-600 leading-relaxed font-light tracking-wide max-w-xs mx-auto">
Best price guarantee in the market
</p>
</div>
<div className="text-center group relative">
<div
className="w-16 h-16 md:w-18 md:h-18 bg-gradient-to-br from-[#d4af37]/15 via-[#f5d76e]/10 to-[#d4af37]/15
rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-5
shadow-lg shadow-[#d4af37]/15 border border-[#d4af37]/25
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30 group-hover:border-[#d4af37]/40
transition-all duration-300 backdrop-blur-sm"
>
<span className="text-3xl">🎧</span>
</div>
<h3
className="text-lg md:text-xl font-serif font-semibold mb-2 md:mb-3
text-gray-900 group-hover:text-[#d4af37] transition-colors duration-300 tracking-tight leading-tight"
>
24/7 Support
</h3>
<p className="text-sm md:text-base text-gray-600 leading-relaxed font-light tracking-wide max-w-xs mx-auto">
Support team always ready to serve
</p>
</div>
</>
)}
) : null}
</div>
</div>
</section>

View File

@@ -53,23 +53,12 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
};
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 (
<div
@@ -77,7 +66,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
>
{}
<div className="relative w-full h-full">
{displayBanners.map((banner, index) => (
{banners.map((banner, index) => (
<div
key={banner.id || index}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
@@ -95,9 +84,6 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
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';
}}
/>
</a>
) : (
@@ -105,9 +91,6 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
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';
}}
/>
)}
</div>
@@ -272,7 +255,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
</div>
{}
{displayBanners.length > 1 && (
{banners.length > 1 && (
<>
<button
onClick={goToPrevious}
@@ -315,14 +298,14 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
)}
{}
{displayBanners.length > 1 && (
{banners.length > 1 && (
<div
className={`absolute left-1/2
-translate-x-1/2 flex gap-2 sm:gap-2.5 z-40 pointer-events-auto
bg-black/40 px-3 py-2 rounded-full
border border-white/10 ${children ? 'bottom-16 sm:bottom-20 md:bottom-24 lg:bottom-28' : 'bottom-2 sm:bottom-4'}`}
>
{displayBanners.map((_, index) => (
{banners.map((_, index) => (
<button
key={index}
type="button"

View File

@@ -1,18 +1,13 @@
import React from 'react';
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
import { Outlet, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Sparkles,
LogOut,
Menu,
X,
User,
} from 'lucide-react';
import useAuthStore from '../store/useAuthStore';
import { useResponsive } from '../shared/hooks/useResponsive';
const HousekeepingLayout: React.FC = () => {
const { isMobile } = useResponsive();
const [sidebarOpen, setSidebarOpen] = React.useState(!isMobile);
const location = useLocation();
const navigate = useNavigate();
const { userInfo, logout } = useAuthStore();
@@ -25,96 +20,55 @@ const HousekeepingLayout: React.FC = () => {
}
};
const navigation = [
{ name: 'Dashboard', href: '/housekeeping/dashboard', icon: LayoutDashboard },
];
const isActive = (path: string) => location.pathname === path;
return (
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
{/* Mobile menu button */}
{isMobile && (
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="fixed top-4 left-4 z-50 p-2 bg-white rounded-lg shadow-lg"
>
{sidebarOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
)}
{/* Sidebar */}
<div
className={`
fixed lg:static inset-y-0 left-0 z-40
w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}
>
<div className="flex flex-col h-full">
{/* Logo/Brand */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div className="flex items-center space-x-2">
<LayoutDashboard className="w-8 h-8 text-blue-600" />
<span className="text-xl font-bold text-gray-900">Housekeeping</span>
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 overflow-x-hidden">
{/* Luxury Top Navigation Bar */}
<header className="sticky top-0 z-50 bg-white/95 backdrop-blur-xl border-b border-[#d4af37]/20 shadow-sm">
<div className="w-full max-w-7xl mx-auto px-3 sm:px-4 md:px-6 lg:px-8">
<div className="flex items-center justify-between h-14 sm:h-16">
{/* Logo/Brand */}
<div className="flex items-center space-x-2 sm:space-x-3 min-w-0 flex-1">
<div className="relative flex-shrink-0">
<div className="absolute inset-0 bg-gradient-to-r from-[#d4af37] to-[#c9a227] rounded-lg blur-sm opacity-50"></div>
<div className="relative bg-gradient-to-r from-[#d4af37] to-[#c9a227] p-1.5 sm:p-2 rounded-lg">
<Sparkles className="w-4 h-4 sm:w-5 sm:h-5 md:w-6 md:h-6 text-white" />
</div>
</div>
<div className="min-w-0 flex-1">
<h1 className="text-sm sm:text-base md:text-lg lg:text-xl font-serif font-bold text-gray-900 tracking-tight truncate">
Enterprise Housekeeping
</h1>
<p className="hidden xs:block text-[10px] sm:text-xs text-gray-500 font-light truncate">Luxury Management System</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2">
{navigation.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.name}
to={item.href}
onClick={() => isMobile && setSidebarOpen(false)}
className={`
flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors
${isActive(item.href)
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-700 hover:bg-gray-50'
}
`}
>
<Icon className="w-5 h-5" />
<span>{item.name}</span>
</Link>
);
})}
</nav>
{/* User info and logout */}
<div className="p-4 border-t border-gray-200">
<div className="mb-3 px-4 py-2">
<p className="text-sm font-medium text-gray-900">{userInfo?.name || userInfo?.email || 'User'}</p>
<p className="text-xs text-gray-500 capitalize">{userInfo?.role || 'housekeeping'}</p>
{/* User Menu */}
<div className="flex items-center space-x-2 sm:space-x-3 flex-shrink-0">
<div className="hidden md:flex items-center space-x-2 lg:space-x-3 px-3 lg:px-4 py-1.5 lg:py-2 rounded-lg bg-gradient-to-r from-gray-50 to-gray-100/50 border border-gray-200/50">
<div className="w-7 h-7 lg:w-8 lg:h-8 rounded-full bg-gradient-to-br from-[#d4af37] to-[#c9a227] flex items-center justify-center flex-shrink-0">
<User className="w-3.5 h-3.5 lg:w-4 lg:h-4 text-white" />
</div>
<div className="text-left min-w-0 hidden lg:block">
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate max-w-[120px]">{userInfo?.name || userInfo?.email || 'User'}</p>
<p className="text-[10px] lg:text-xs text-gray-500 capitalize truncate">{userInfo?.role || 'housekeeping'}</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center justify-center space-x-1 sm:space-x-2 px-2 sm:px-3 md:px-4 py-1.5 sm:py-2 text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-all duration-200 border border-transparent hover:border-gray-200 flex-shrink-0"
>
<LogOut className="w-4 h-4 flex-shrink-0" />
<span className="hidden sm:inline text-xs sm:text-sm font-medium">Logout</span>
</button>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center space-x-3 px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
>
<LogOut className="w-5 h-5" />
<span>Logout</span>
</button>
</div>
</div>
</div>
{/* Overlay for mobile */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-30"
onClick={() => setSidebarOpen(false)}
/>
)}
</header>
{/* Main content */}
<div className="flex-1 overflow-auto lg:ml-0">
<div className={`min-h-screen ${isMobile ? 'pt-20' : 'pt-0'}`}>
<Outlet />
</div>
</div>
<main className="w-full overflow-x-hidden">
<Outlet />
</main>
</div>
);
};

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 {

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -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);

View File

@@ -34,16 +34,22 @@ const Footer: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(null);
const [enabledPages, setEnabledPages] = useState<Set<string>>(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 = () => {
</div>
{/* Quick Links */}
{quickLinks.length > 0 && (
<div>
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<span className="relative z-10">Quick Links</span>
@@ -303,8 +313,10 @@ const Footer: React.FC = () => {
))}
</ul>
</div>
)}
{/* Guest Services */}
{supportLinks.length > 0 && (
<div>
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<span className="relative z-10">Guest Services</span>
@@ -324,6 +336,7 @@ const Footer: React.FC = () => {
))}
</ul>
</div>
)}
{/* Contact Information */}
<div>
@@ -331,7 +344,9 @@ const Footer: React.FC = () => {
<span className="relative z-10">Contact</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>
{(displayAddress || displayPhone || displayEmail) && (
<ul className="space-y-5 sm:space-y-6">
{displayAddress && (
<li className="flex items-start space-x-4 group">
<div className="relative mt-1 flex-shrink-0">
<div className="p-2 bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5 rounded-lg border border-[#d4af37]/20 group-hover:border-[#d4af37]/40 transition-all duration-300">
@@ -340,15 +355,16 @@ const Footer: React.FC = () => {
<div className="absolute inset-0 bg-[#d4af37]/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
</div>
<span className="text-sm sm:text-base text-gray-400 group-hover:text-gray-300 transition-colors leading-relaxed font-light pt-1">
{(displayAddress
{displayAddress
.split('\n').map((line, i) => (
<React.Fragment key={i}>
{line}
{i < displayAddress.split('\n').length - 1 && <br />}
</React.Fragment>
)))}
))}
</span>
</li>
)}
{displayPhone && (
<li className="flex items-center space-x-4 group">
<div className="relative flex-shrink-0">
@@ -376,6 +392,7 @@ const Footer: React.FC = () => {
</li>
)}
</ul>
)}
{/* Rating Section */}
<div className="mt-8 sm:mt-10 pt-6 sm:pt-8 border-t border-gray-800/50">

View File

@@ -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%);
}