updates
This commit is contained in:
Binary file not shown.
@@ -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(
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
Binary file not shown.
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
571
Frontend/src/features/auth/pages/AccountantLoginPage.tsx
Normal file
571
Frontend/src/features/auth/pages/AccountantLoginPage.tsx
Normal 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;
|
||||
|
||||
571
Frontend/src/features/auth/pages/AdminLoginPage.tsx
Normal file
571
Frontend/src/features/auth/pages/AdminLoginPage.tsx
Normal 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;
|
||||
|
||||
571
Frontend/src/features/auth/pages/HousekeepingLoginPage.tsx
Normal file
571
Frontend/src/features/auth/pages/HousekeepingLoginPage.tsx
Normal 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;
|
||||
|
||||
576
Frontend/src/features/auth/pages/StaffLoginPage.tsx
Normal file
576
Frontend/src/features/auth/pages/StaffLoginPage.tsx
Normal 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;
|
||||
|
||||
5
Frontend/src/features/auth/pages/index.ts
Normal file
5
Frontend/src/features/auth/pages/index.ts
Normal 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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user