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)
|
request_id = getattr(request.state, 'request_id', None)
|
||||||
|
|
||||||
try:
|
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'):
|
if result.get('requires_mfa'):
|
||||||
# Log MFA required
|
# Log MFA required
|
||||||
user = db.query(User).filter(User.email == login_request.email.lower().strip()).first()
|
user = db.query(User).filter(User.email == login_request.email.lower().strip()).first()
|
||||||
if user:
|
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(
|
await audit_service.log_action(
|
||||||
db=db,
|
db=db,
|
||||||
action='login_mfa_required',
|
action='login_mfa_required',
|
||||||
@@ -212,6 +240,31 @@ async def login(
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
logger.warning(f'Failed to create session during login: {str(e)}')
|
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
|
# Log successful login
|
||||||
await audit_service.log_action(
|
await audit_service.log_action(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -229,9 +282,25 @@ async def login(
|
|||||||
return {'status': 'success', 'data': {'user': result['user']}}
|
return {'status': 'success', 'data': {'user': result['user']}}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
error_message = str(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(
|
await audit_service.log_action(
|
||||||
db=db,
|
db=db,
|
||||||
action='login_failed',
|
action='login_failed',
|
||||||
@@ -241,10 +310,10 @@ async def login(
|
|||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
details={'email': login_request.email},
|
details={'email': login_request.email},
|
||||||
status='failed',
|
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)
|
@router.post('/refresh-token', response_model=TokenResponse)
|
||||||
async def refresh_token(
|
async def refresh_token(
|
||||||
|
|||||||
Binary file not shown.
@@ -32,6 +32,7 @@ class LoginRequest(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
rememberMe: Optional[bool] = False
|
rememberMe: Optional[bool] = False
|
||||||
mfaToken: Optional[str] = None
|
mfaToken: Optional[str] = None
|
||||||
|
expectedRole: Optional[str] = None # Optional role validation for role-specific login endpoints
|
||||||
|
|
||||||
class RefreshTokenRequest(BaseModel):
|
class RefreshTokenRequest(BaseModel):
|
||||||
refreshToken: Optional[str] = None
|
refreshToken: Optional[str] = None
|
||||||
|
|||||||
Binary file not shown.
@@ -46,6 +46,16 @@ class AuthService:
|
|||||||
else:
|
else:
|
||||||
logger.warning(error_msg)
|
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
|
# Refresh secret should be different from access secret
|
||||||
self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET")
|
self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET")
|
||||||
if not self.jwt_refresh_secret:
|
if not self.jwt_refresh_secret:
|
||||||
@@ -187,7 +197,7 @@ class AuthService:
|
|||||||
"refreshToken": tokens["refreshToken"]
|
"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 ""
|
email = email.lower().strip() if email else ""
|
||||||
if not email:
|
if not email:
|
||||||
@@ -206,9 +216,9 @@ class AuthService:
|
|||||||
# Check if account is locked (reset if lockout expired)
|
# Check if account is locked (reset if lockout expired)
|
||||||
if user.locked_until:
|
if user.locked_until:
|
||||||
if user.locked_until > datetime.utcnow():
|
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})")
|
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:
|
else:
|
||||||
# Lockout expired, reset it
|
# Lockout expired, reset it
|
||||||
user.locked_until = None
|
user.locked_until = None
|
||||||
@@ -230,12 +240,13 @@ class AuthService:
|
|||||||
user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration)
|
user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration)
|
||||||
logger.warning(f"Account locked due to {user.failed_login_attempts} failed login attempts: {email}")
|
logger.warning(f"Account locked due to {user.failed_login_attempts} failed login attempts: {email}")
|
||||||
db.commit()
|
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:
|
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)")
|
logger.warning(f"Login attempt with invalid password for user: {email} ({user.failed_login_attempts}/{max_attempts} failed attempts)")
|
||||||
db.commit()
|
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 user.mfa_enabled:
|
||||||
if not mfa_token:
|
if not mfa_token:
|
||||||
@@ -258,12 +269,21 @@ class AuthService:
|
|||||||
user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration)
|
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}")
|
logger.warning(f"Account locked due to {user.failed_login_attempts} failed attempts (MFA failure): {email}")
|
||||||
db.commit()
|
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:
|
else:
|
||||||
remaining_attempts = max_attempts - user.failed_login_attempts
|
|
||||||
db.commit()
|
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
|
# Reset failed login attempts and unlock account on successful login
|
||||||
if user.failed_login_attempts > 0 or user.locked_until:
|
if user.failed_login_attempts > 0 or user.locked_until:
|
||||||
user.failed_login_attempts = 0
|
user.failed_login_attempts = 0
|
||||||
@@ -447,7 +467,8 @@ class AuthService:
|
|||||||
|
|
||||||
db.query(PasswordResetToken).filter(PasswordResetToken.user_id == user.id).delete()
|
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(
|
reset_token_obj = PasswordResetToken(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
token=hashed_token,
|
token=hashed_token,
|
||||||
|
|||||||
Binary file not shown.
@@ -50,6 +50,18 @@ def get_jwt_secret() -> str:
|
|||||||
else:
|
else:
|
||||||
logger.warning(error_msg)
|
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
|
return jwt_secret
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ const AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage'));
|
|||||||
const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage'));
|
const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage'));
|
||||||
const AccountantProfilePage = lazy(() => import('./pages/accountant/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'));
|
const NotFoundPage = lazy(() => import('./shared/pages/NotFoundPage'));
|
||||||
|
|
||||||
// Component to track navigation changes - must be inside Router
|
// Component to track navigation changes - must be inside Router
|
||||||
@@ -465,6 +471,40 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</Route>
|
</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
|
<Route
|
||||||
path="/reset-password/:token"
|
path="/reset-password/:token"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import useAuthStore from '../../../store/useAuthStore';
|
import useAuthStore from '../../../store/useAuthStore';
|
||||||
import { useAuthModal } from '../contexts/AuthModalContext';
|
|
||||||
|
|
||||||
interface AccountantRouteProps {
|
interface AccountantRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -11,14 +10,6 @@ const AccountantRoute: React.FC<AccountantRouteProps> = ({
|
|||||||
children
|
children
|
||||||
}) => {
|
}) => {
|
||||||
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
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) {
|
if (isLoading) {
|
||||||
return (
|
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) {
|
if (!isAuthenticated) {
|
||||||
return null; // Modal will be shown by AuthModalManager
|
return <Navigate to="/accountant/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is accountant
|
// 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 { Navigate } from 'react-router-dom';
|
||||||
import useAuthStore from '../../../store/useAuthStore';
|
import useAuthStore from '../../../store/useAuthStore';
|
||||||
import { useAuthModal } from '../contexts/AuthModalContext';
|
|
||||||
|
|
||||||
interface AdminRouteProps {
|
interface AdminRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -19,14 +18,6 @@ const AdminRoute: React.FC<AdminRouteProps> = ({
|
|||||||
children
|
children
|
||||||
}) => {
|
}) => {
|
||||||
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +40,7 @@ const AdminRoute: React.FC<AdminRouteProps> = ({
|
|||||||
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
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 });
|
await forgotPassword({ email: data.email });
|
||||||
setIsSuccess(true);
|
setIsSuccess(true);
|
||||||
} catch (error) {
|
} 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 { Navigate } from 'react-router-dom';
|
||||||
import useAuthStore from '../../../store/useAuthStore';
|
import useAuthStore from '../../../store/useAuthStore';
|
||||||
import { useAuthModal } from '../contexts/AuthModalContext';
|
|
||||||
|
|
||||||
interface HousekeepingRouteProps {
|
interface HousekeepingRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -9,13 +8,6 @@ interface HousekeepingRouteProps {
|
|||||||
|
|
||||||
const HousekeepingRoute: React.FC<HousekeepingRouteProps> = ({ children }) => {
|
const HousekeepingRoute: React.FC<HousekeepingRouteProps> = ({ children }) => {
|
||||||
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
||||||
const { openModal } = useAuthModal();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading && !isAuthenticated) {
|
|
||||||
openModal('login');
|
|
||||||
}
|
|
||||||
}, [isLoading, isAuthenticated, openModal]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +21,7 @@ const HousekeepingRoute: React.FC<HousekeepingRouteProps> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
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
|
// 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 { recaptchaService } from '../../../features/system/services/systemSettingsService';
|
||||||
import { useAntibotForm } from '../hooks/useAntibotForm';
|
import { useAntibotForm } from '../hooks/useAntibotForm';
|
||||||
import HoneypotField from '../../../shared/components/HoneypotField';
|
import HoneypotField from '../../../shared/components/HoneypotField';
|
||||||
|
import authService from '../services/authService';
|
||||||
|
|
||||||
const mfaTokenSchema = yup.object().shape({
|
const mfaTokenSchema = yup.object().shape({
|
||||||
mfaToken: yup
|
mfaToken: yup
|
||||||
@@ -27,7 +28,7 @@ type MFATokenFormData = yup.InferType<typeof mfaTokenSchema>;
|
|||||||
|
|
||||||
const LoginModal: React.FC = () => {
|
const LoginModal: React.FC = () => {
|
||||||
const { closeModal, openModal } = useAuthModal();
|
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 { settings } = useCompanySettings();
|
||||||
const navigate = useNavigate();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && isAuthenticated && !requiresMFA && userInfo) {
|
if (!isLoading && isAuthenticated && !requiresMFA && userInfo) {
|
||||||
closeModal();
|
|
||||||
|
|
||||||
// Redirect to role-specific dashboard
|
|
||||||
const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase();
|
const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase();
|
||||||
|
|
||||||
if (role === 'admin') {
|
// Reject non-customer roles - they should use their dedicated login pages
|
||||||
navigate('/admin/dashboard', { replace: true });
|
// This should not happen if onSubmit logic works correctly, but handle it as safety
|
||||||
} else if (role === 'staff') {
|
if (role === 'admin' || role === 'staff' || role === 'accountant' || role === 'housekeeping') {
|
||||||
navigate('/staff/dashboard', { replace: true });
|
closeModal();
|
||||||
} else if (role === 'accountant') {
|
navigate(`/${role}/login`, { replace: true });
|
||||||
navigate('/accountant/dashboard', { replace: true });
|
return;
|
||||||
} else if (role === 'housekeeping') {
|
|
||||||
navigate('/housekeeping/dashboard', { replace: true });
|
|
||||||
} else {
|
|
||||||
// Customer or default - go to customer dashboard
|
|
||||||
navigate('/dashboard', { replace: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only allow customers - close modal and redirect to dashboard
|
||||||
|
closeModal();
|
||||||
|
navigate('/dashboard', { replace: true });
|
||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, requiresMFA, userInfo, closeModal, navigate]);
|
}, [isLoading, isAuthenticated, requiresMFA, userInfo, closeModal, navigate]);
|
||||||
|
|
||||||
@@ -125,15 +124,120 @@ const LoginModal: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await login({
|
// Call login API directly to check role BEFORE showing success toast
|
||||||
email: data.email,
|
useAuthStore.setState({ isLoading: true, error: null });
|
||||||
password: data.password,
|
|
||||||
rememberMe: data.rememberMe,
|
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) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
}
|
||||||
setRecaptchaToken(null);
|
setRecaptchaToken(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -141,7 +245,103 @@ const LoginModal: React.FC = () => {
|
|||||||
const onSubmitMFA = async (data: MFATokenFormData) => {
|
const onSubmitMFA = async (data: MFATokenFormData) => {
|
||||||
try {
|
try {
|
||||||
clearError();
|
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) {
|
} catch (error) {
|
||||||
console.error('MFA verification error:', error);
|
console.error('MFA verification error:', error);
|
||||||
}
|
}
|
||||||
@@ -397,7 +597,9 @@ const LoginModal: React.FC = () => {
|
|||||||
<Recaptcha
|
<Recaptcha
|
||||||
onChange={(token) => setRecaptchaToken(token)}
|
onChange={(token) => setRecaptchaToken(token)}
|
||||||
onError={(error) => {
|
onError={(error) => {
|
||||||
console.error('reCAPTCHA error:', error);
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('reCAPTCHA error:', error);
|
||||||
|
}
|
||||||
setRecaptchaToken(null);
|
setRecaptchaToken(null);
|
||||||
}}
|
}}
|
||||||
theme="light"
|
theme="light"
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ const RegisterModal: React.FC = () => {
|
|||||||
|
|
||||||
setRecaptchaToken(null);
|
setRecaptchaToken(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Register error:', error);
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('Register error:', error);
|
||||||
|
}
|
||||||
setRecaptchaToken(null);
|
setRecaptchaToken(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -407,7 +409,9 @@ const RegisterModal: React.FC = () => {
|
|||||||
<Recaptcha
|
<Recaptcha
|
||||||
onChange={(token) => setRecaptchaToken(token)}
|
onChange={(token) => setRecaptchaToken(token)}
|
||||||
onError={(error) => {
|
onError={(error) => {
|
||||||
console.error('reCAPTCHA error:', error);
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('reCAPTCHA error:', error);
|
||||||
|
}
|
||||||
setRecaptchaToken(null);
|
setRecaptchaToken(null);
|
||||||
}}
|
}}
|
||||||
theme="light"
|
theme="light"
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ const ResetPasswordModal: React.FC<ResetPasswordModalProps> = ({ token }) => {
|
|||||||
});
|
});
|
||||||
setIsSuccess(true);
|
setIsSuccess(true);
|
||||||
} catch (error) {
|
} 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 { Navigate } from 'react-router-dom';
|
||||||
import useAuthStore from '../../../store/useAuthStore';
|
import useAuthStore from '../../../store/useAuthStore';
|
||||||
import { useAuthModal } from '../contexts/AuthModalContext';
|
|
||||||
|
|
||||||
interface StaffRouteProps {
|
interface StaffRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -11,14 +10,6 @@ const StaffRoute: React.FC<StaffRouteProps> = ({
|
|||||||
children
|
children
|
||||||
}) => {
|
}) => {
|
||||||
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
||||||
const { openModal } = useAuthModal();
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading && !isAuthenticated) {
|
|
||||||
openModal('login');
|
|
||||||
}
|
|
||||||
}, [isLoading, isAuthenticated, openModal]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +32,7 @@ const StaffRoute: React.FC<StaffRouteProps> = ({
|
|||||||
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
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);
|
setIsValidating(false);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Antibot validation error:', error);
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('Antibot validation error:', error);
|
||||||
|
}
|
||||||
setIsValidating(false);
|
setIsValidating(false);
|
||||||
return 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;
|
password: string;
|
||||||
rememberMe?: boolean;
|
rememberMe?: boolean;
|
||||||
mfaToken?: string;
|
mfaToken?: string;
|
||||||
|
expectedRole?: string; // Optional role validation for role-specific login endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
@@ -80,7 +81,9 @@ const authService = {
|
|||||||
try {
|
try {
|
||||||
await apiClient.post('/api/auth/logout');
|
await apiClient.post('/api/auth/logout');
|
||||||
} catch (error) {
|
} 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 AboutPage: React.FC = () => {
|
||||||
const { settings } = useCompanySettings();
|
const { settings } = useCompanySettings();
|
||||||
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
||||||
|
const [apiError, setApiError] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPageContent = async () => {
|
const fetchPageContent = async () => {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(false);
|
||||||
const response = await pageContentService.getAboutContent();
|
const response = await pageContentService.getAboutContent();
|
||||||
if (response.status === 'success' && response.data?.page_content) {
|
if (response.status === 'success' && response.data?.page_content) {
|
||||||
setPageContent(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);
|
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) {
|
} catch (err: any) {
|
||||||
console.error('Error fetching page content:', err);
|
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';
|
// Only use company settings from API, no hardcoded fallbacks
|
||||||
const displayEmail = settings.company_email || 'info@luxuryhotel.com';
|
const displayPhone = settings.company_phone || null;
|
||||||
const displayAddress = settings.company_address || '123 Luxury Street\nCity, State 12345\nCountry';
|
const displayEmail = settings.company_email || null;
|
||||||
|
const displayAddress = settings.company_address || null;
|
||||||
|
|
||||||
|
|
||||||
const defaultValues = [
|
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) => ({
|
? pageContent.values.map((v: any) => ({
|
||||||
icon: v.icon || defaultValues.find(d => d.title === v.title)?.icon || 'Heart',
|
icon: v.icon || defaultValues.find(d => d.title === v.title)?.icon || 'Heart',
|
||||||
title: v.title,
|
title: v.title,
|
||||||
description: v.description
|
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) => ({
|
? pageContent.features.map((f: any) => ({
|
||||||
icon: f.icon || defaultFeatures.find(d => d.title === f.title)?.icon || 'Star',
|
icon: f.icon || defaultFeatures.find(d => d.title === f.title)?.icon || 'Star',
|
||||||
title: f.title,
|
title: f.title,
|
||||||
description: f.description
|
description: f.description
|
||||||
}))
|
}))
|
||||||
: defaultFeatures;
|
: (pageContent && !apiError ? defaultFeatures : []);
|
||||||
|
|
||||||
|
|
||||||
const team = pageContent?.team && typeof pageContent.team === 'string'
|
const team = pageContent?.team && typeof pageContent.team === 'string'
|
||||||
@@ -130,6 +143,29 @@ const AboutPage: React.FC = () => {
|
|||||||
return IconComponent;
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 via-white to-slate-50">
|
<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>
|
</section>
|
||||||
|
|
||||||
{}
|
{}
|
||||||
|
{values.length > 0 && (
|
||||||
<section className="py-20 md:py-28 bg-gradient-to-b from-slate-50 via-white to-slate-50 relative">
|
<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="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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{}
|
{}
|
||||||
|
{features.length > 0 && (
|
||||||
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
|
<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="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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{}
|
|
||||||
{(pageContent?.mission || pageContent?.vision) && (
|
{(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">
|
<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">
|
<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.
|
We'd love to hear from you. Contact us for reservations or inquiries.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{(displayAddress || displayPhone || displayEmail) && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-10 mb-16">
|
<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="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">
|
<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" />
|
<MapPin className="w-8 h-8 text-white drop-shadow-md" />
|
||||||
@@ -588,6 +629,8 @@ const AboutPage: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="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">
|
<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" />
|
<Phone className="w-8 h-8 text-white drop-shadow-md" />
|
||||||
@@ -601,6 +644,8 @@ const AboutPage: React.FC = () => {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="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">
|
<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" />
|
<Mail className="w-8 h-8 text-white drop-shadow-md" />
|
||||||
@@ -614,7 +659,9 @@ const AboutPage: React.FC = () => {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link
|
<Link
|
||||||
to="/rooms"
|
to="/rooms"
|
||||||
|
|||||||
@@ -34,10 +34,23 @@ const HomePage: React.FC = () => {
|
|||||||
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
|
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
|
||||||
const [, setIsLoadingContent] = useState(true);
|
const [, setIsLoadingContent] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [apiError, setApiError] = useState(false);
|
||||||
|
const [apiErrorMessage, setApiErrorMessage] = useState<string>('');
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||||
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!lightboxOpen) return;
|
if (!lightboxOpen) return;
|
||||||
@@ -86,6 +99,7 @@ const HomePage: React.FC = () => {
|
|||||||
const fetchPageContent = async () => {
|
const fetchPageContent = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoadingContent(true);
|
setIsLoadingContent(true);
|
||||||
|
setApiError(false);
|
||||||
const response = await pageContentService.getHomeContent();
|
const response = await pageContentService.getHomeContent();
|
||||||
if (response.status === 'success' && response.data?.page_content) {
|
if (response.status === 'success' && response.data?.page_content) {
|
||||||
const content = response.data.page_content;
|
const content = response.data.page_content;
|
||||||
@@ -198,11 +212,17 @@ const HomePage: React.FC = () => {
|
|||||||
document.head.appendChild(metaDescription);
|
document.head.appendChild(metaDescription);
|
||||||
}
|
}
|
||||||
metaDescription.setAttribute('content', content.meta_description);
|
metaDescription.setAttribute('content', content.meta_description);
|
||||||
|
} else {
|
||||||
|
setPageContent(null);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setPageContent(null);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error fetching page content:', err);
|
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 {
|
} finally {
|
||||||
setIsLoadingContent(false);
|
setIsLoadingContent(false);
|
||||||
}
|
}
|
||||||
@@ -225,11 +245,14 @@ const HomePage: React.FC = () => {
|
|||||||
response.status === 'success'
|
response.status === 'success'
|
||||||
) {
|
) {
|
||||||
setBanners(response.data?.banners || []);
|
setBanners(response.data?.banners || []);
|
||||||
|
} else {
|
||||||
|
setBanners([]);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error fetching banners:', err);
|
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 {
|
} finally {
|
||||||
setIsLoadingBanners(false);
|
setIsLoadingBanners(false);
|
||||||
}
|
}
|
||||||
@@ -261,7 +284,7 @@ const HomePage: React.FC = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
setFeaturedRooms([]);
|
||||||
setError(
|
setError(
|
||||||
response.message ||
|
response.message ||
|
||||||
'Unable to load room list'
|
'Unable to load room list'
|
||||||
@@ -269,7 +292,7 @@ const HomePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error fetching rooms:', err);
|
console.error('Error fetching rooms:', err);
|
||||||
|
setFeaturedRooms([]);
|
||||||
|
|
||||||
if (err.response?.status === 429) {
|
if (err.response?.status === 429) {
|
||||||
setError(
|
setError(
|
||||||
@@ -307,10 +330,14 @@ const HomePage: React.FC = () => {
|
|||||||
response.status === 'success'
|
response.status === 'success'
|
||||||
) {
|
) {
|
||||||
setNewestRooms(response.data?.rooms || []);
|
setNewestRooms(response.data?.rooms || []);
|
||||||
|
} else {
|
||||||
|
setNewestRooms([]);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error fetching newest rooms:', err);
|
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 {
|
} finally {
|
||||||
setIsLoadingNewest(false);
|
setIsLoadingNewest(false);
|
||||||
}
|
}
|
||||||
@@ -321,6 +348,60 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
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
|
<section
|
||||||
className="relative w-screen -mt-6"
|
className="relative w-screen -mt-6"
|
||||||
@@ -331,13 +412,13 @@ const HomePage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{isLoadingBanners ? (
|
{isLoadingBanners ? (
|
||||||
<BannerSkeleton />
|
<BannerSkeleton />
|
||||||
) : (
|
) : banners.length > 0 ? (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<BannerCarousel banners={banners}>
|
<BannerCarousel banners={banners}>
|
||||||
<SearchRoomForm className="overlay" />
|
<SearchRoomForm className="overlay" />
|
||||||
</BannerCarousel>
|
</BannerCarousel>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50/30 relative overflow-hidden">
|
<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 className="h-0.5 w-16 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
|
||||||
</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">
|
<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>
|
</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">
|
<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>
|
</p>
|
||||||
|
|
||||||
{}
|
{}
|
||||||
@@ -439,7 +520,8 @@ const HomePage: React.FC = () => {
|
|||||||
(f: any) => f && (f.title || f.description)
|
(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">
|
<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">
|
<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>
|
||||||
))
|
))
|
||||||
) : (
|
) : null}
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -53,23 +53,12 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const defaultBanner: Banner = {
|
// Don't render if no banners - only show banners from API
|
||||||
id: 0,
|
if (banners.length === 0) {
|
||||||
title: 'Welcome to Hotel Booking',
|
return null;
|
||||||
image_url: '/images/default-banner.jpg',
|
}
|
||||||
position: 'home',
|
|
||||||
display_order: 0,
|
|
||||||
is_active: true,
|
|
||||||
created_at: '',
|
|
||||||
updated_at: '',
|
|
||||||
description: undefined,
|
|
||||||
link_url: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayBanners = banners.length > 0
|
const currentBanner = banners[currentIndex];
|
||||||
? banners
|
|
||||||
: [defaultBanner];
|
|
||||||
const currentBanner = displayBanners[currentIndex];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -77,7 +66,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
|||||||
>
|
>
|
||||||
{}
|
{}
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
{displayBanners.map((banner, index) => (
|
{banners.map((banner, index) => (
|
||||||
<div
|
<div
|
||||||
key={banner.id || index}
|
key={banner.id || index}
|
||||||
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
|
||||||
@@ -95,9 +84,6 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
|||||||
src={banner.image_url}
|
src={banner.image_url}
|
||||||
alt={banner.title}
|
alt={banner.title}
|
||||||
className="w-full h-full object-cover object-center transition-transform duration-1000 ease-out hover:scale-105"
|
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>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
@@ -105,9 +91,6 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
|||||||
src={banner.image_url}
|
src={banner.image_url}
|
||||||
alt={banner.title}
|
alt={banner.title}
|
||||||
className="w-full h-full object-cover object-center transition-transform duration-1000 ease-out hover:scale-105"
|
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>
|
</div>
|
||||||
@@ -272,7 +255,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{}
|
{}
|
||||||
{displayBanners.length > 1 && (
|
{banners.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={goToPrevious}
|
onClick={goToPrevious}
|
||||||
@@ -315,14 +298,14 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{}
|
{}
|
||||||
{displayBanners.length > 1 && (
|
{banners.length > 1 && (
|
||||||
<div
|
<div
|
||||||
className={`absolute left-1/2
|
className={`absolute left-1/2
|
||||||
-translate-x-1/2 flex gap-2 sm:gap-2.5 z-40 pointer-events-auto
|
-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
|
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'}`}
|
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
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
Sparkles,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
User,
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import useAuthStore from '../store/useAuthStore';
|
import useAuthStore from '../store/useAuthStore';
|
||||||
import { useResponsive } from '../shared/hooks/useResponsive';
|
|
||||||
|
|
||||||
const HousekeepingLayout: React.FC = () => {
|
const HousekeepingLayout: React.FC = () => {
|
||||||
const { isMobile } = useResponsive();
|
|
||||||
const [sidebarOpen, setSidebarOpen] = React.useState(!isMobile);
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { userInfo, logout } = useAuthStore();
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 overflow-x-hidden">
|
||||||
{/* Mobile menu button */}
|
{/* Luxury Top Navigation Bar */}
|
||||||
{isMobile && (
|
<header className="sticky top-0 z-50 bg-white/95 backdrop-blur-xl border-b border-[#d4af37]/20 shadow-sm">
|
||||||
<button
|
<div className="w-full max-w-7xl mx-auto px-3 sm:px-4 md:px-6 lg:px-8">
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
<div className="flex items-center justify-between h-14 sm:h-16">
|
||||||
className="fixed top-4 left-4 z-50 p-2 bg-white rounded-lg shadow-lg"
|
{/* Logo/Brand */}
|
||||||
>
|
<div className="flex items-center space-x-2 sm:space-x-3 min-w-0 flex-1">
|
||||||
{sidebarOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
<div className="relative flex-shrink-0">
|
||||||
</button>
|
<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" />
|
||||||
{/* Sidebar */}
|
</div>
|
||||||
<div
|
</div>
|
||||||
className={`
|
<div className="min-w-0 flex-1">
|
||||||
fixed lg:static inset-y-0 left-0 z-40
|
<h1 className="text-sm sm:text-base md:text-lg lg:text-xl font-serif font-bold text-gray-900 tracking-tight truncate">
|
||||||
w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out
|
Enterprise Housekeeping
|
||||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
</h1>
|
||||||
`}
|
<p className="hidden xs:block text-[10px] sm:text-xs text-gray-500 font-light truncate">Luxury Management System</p>
|
||||||
>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* User Menu */}
|
||||||
<nav className="flex-1 px-4 py-6 space-y-2">
|
<div className="flex items-center space-x-2 sm:space-x-3 flex-shrink-0">
|
||||||
{navigation.map((item) => {
|
<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">
|
||||||
const Icon = item.icon;
|
<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">
|
||||||
return (
|
<User className="w-3.5 h-3.5 lg:w-4 lg:h-4 text-white" />
|
||||||
<Link
|
</div>
|
||||||
key={item.name}
|
<div className="text-left min-w-0 hidden lg:block">
|
||||||
to={item.href}
|
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate max-w-[120px]">{userInfo?.name || userInfo?.email || 'User'}</p>
|
||||||
onClick={() => isMobile && setSidebarOpen(false)}
|
<p className="text-[10px] lg:text-xs text-gray-500 capitalize truncate">{userInfo?.role || 'housekeeping'}</p>
|
||||||
className={`
|
</div>
|
||||||
flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors
|
</div>
|
||||||
${isActive(item.href)
|
<button
|
||||||
? 'bg-blue-50 text-blue-700 font-medium'
|
onClick={handleLogout}
|
||||||
: 'text-gray-700 hover:bg-gray-50'
|
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>
|
||||||
<Icon className="w-5 h-5" />
|
</button>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{/* Overlay for mobile */}
|
|
||||||
{isMobile && sidebarOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 z-30"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 overflow-auto lg:ml-0">
|
<main className="w-full overflow-x-hidden">
|
||||||
<div className={`min-h-screen ${isMobile ? 'pt-20' : 'pt-0'}`}>
|
<Outlet />
|
||||||
<Outlet />
|
</main>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,12 +93,29 @@ const AccountantDashboardPage: React.FC = () => {
|
|||||||
totalPayments: response.data.payments.length,
|
totalPayments: response.data.payments.length,
|
||||||
pendingPayments: pendingPayments.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) {
|
} catch (err: any) {
|
||||||
// Handle AbortError silently
|
// Handle AbortError silently
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setRecentPayments([]);
|
||||||
|
setFinancialSummary(prev => ({
|
||||||
|
...prev,
|
||||||
|
totalRevenue: 0,
|
||||||
|
totalPayments: 0,
|
||||||
|
pendingPayments: 0,
|
||||||
|
}));
|
||||||
logger.error('Error fetching payments', err);
|
logger.error('Error fetching payments', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPayments(false);
|
setLoadingPayments(false);
|
||||||
@@ -139,12 +156,29 @@ const AccountantDashboardPage: React.FC = () => {
|
|||||||
paidInvoices: paidInvoices.length,
|
paidInvoices: paidInvoices.length,
|
||||||
overdueInvoices: overdueInvoices.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) {
|
} catch (err: any) {
|
||||||
// Handle AbortError silently
|
// Handle AbortError silently
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setRecentInvoices([]);
|
||||||
|
setFinancialSummary(prev => ({
|
||||||
|
...prev,
|
||||||
|
totalInvoices: 0,
|
||||||
|
paidInvoices: 0,
|
||||||
|
overdueInvoices: 0,
|
||||||
|
}));
|
||||||
logger.error('Error fetching invoices', err);
|
logger.error('Error fetching invoices', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingInvoices(false);
|
setLoadingInvoices(false);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Search,
|
Search,
|
||||||
@@ -75,6 +75,10 @@ const AuditLogsPage: React.FC = () => {
|
|||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setLogs([]);
|
||||||
|
setTotalPages(1);
|
||||||
|
setTotalItems(0);
|
||||||
logger.error('Error fetching audit logs', error);
|
logger.error('Error fetching audit logs', error);
|
||||||
toast.error(error.response?.data?.message || 'Unable to load audit logs');
|
toast.error(error.response?.data?.message || 'Unable to load audit logs');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -89,12 +89,17 @@ const DashboardPage: React.FC = () => {
|
|||||||
const response = await paymentService.getPayments({ page: 1, limit: 5 });
|
const response = await paymentService.getPayments({ page: 1, limit: 5 });
|
||||||
if (response.success && response.data?.payments) {
|
if (response.success && response.data?.payments) {
|
||||||
setRecentPayments(response.data.payments);
|
setRecentPayments(response.data.payments);
|
||||||
|
} else {
|
||||||
|
// Clear data if response is not successful
|
||||||
|
setRecentPayments([]);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Handle AbortError silently
|
// Handle AbortError silently
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setRecentPayments([]);
|
||||||
logger.error('Error fetching payments', err);
|
logger.error('Error fetching payments', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPayments(false);
|
setLoadingPayments(false);
|
||||||
@@ -125,12 +130,17 @@ const DashboardPage: React.FC = () => {
|
|||||||
const response = await sessionService.getMySessions();
|
const response = await sessionService.getMySessions();
|
||||||
if (response.success && response.data?.sessions) {
|
if (response.success && response.data?.sessions) {
|
||||||
setSessions(response.data.sessions || []);
|
setSessions(response.data.sessions || []);
|
||||||
|
} else {
|
||||||
|
// Clear data if response is not successful
|
||||||
|
setSessions([]);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Handle AbortError silently
|
// Handle AbortError silently
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setSessions([]);
|
||||||
logger.error('Error fetching sessions', err);
|
logger.error('Error fetching sessions', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessions(false);
|
setLoadingSessions(false);
|
||||||
|
|||||||
@@ -65,9 +65,14 @@ const DashboardPage: React.FC = () => {
|
|||||||
const response = await paymentService.getPayments({ page: 1, limit: 5 });
|
const response = await paymentService.getPayments({ page: 1, limit: 5 });
|
||||||
if (response.success && response.data?.payments) {
|
if (response.success && response.data?.payments) {
|
||||||
setRecentPayments(response.data.payments);
|
setRecentPayments(response.data.payments);
|
||||||
|
} else {
|
||||||
|
// Clear data if response is not successful
|
||||||
|
setRecentPayments([]);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name !== 'AbortError') {
|
if (err.name !== 'AbortError') {
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setRecentPayments([]);
|
||||||
logger.error('Error fetching payments', err);
|
logger.error('Error fetching payments', err);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,9 +106,14 @@ const DashboardPage: React.FC = () => {
|
|||||||
const response = await sessionService.getMySessions();
|
const response = await sessionService.getMySessions();
|
||||||
if (response.success && response.data?.sessions) {
|
if (response.success && response.data?.sessions) {
|
||||||
setSessions(response.data.sessions || []);
|
setSessions(response.data.sessions || []);
|
||||||
|
} else {
|
||||||
|
// Clear data if response is not successful
|
||||||
|
setSessions([]);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name !== 'AbortError') {
|
if (err.name !== 'AbortError') {
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setSessions([]);
|
||||||
logger.error('Error fetching sessions', err);
|
logger.error('Error fetching sessions', err);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ const RoomListPage: React.FC = () => {
|
|||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setRooms([]);
|
||||||
|
setPagination({
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
limit: 12,
|
||||||
|
totalPages: 0,
|
||||||
|
});
|
||||||
logger.error('Error fetching rooms', err);
|
logger.error('Error fetching rooms', err);
|
||||||
setError('Unable to load room list. Please try again.');
|
setError('Unable to load room list. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -218,8 +218,13 @@ const ChatManagementPage: React.FC = () => {
|
|||||||
const response = await chatService.listChats();
|
const response = await chatService.listChats();
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setChats(response.data);
|
setChats(response.data);
|
||||||
|
} else {
|
||||||
|
// Clear data if response is not successful
|
||||||
|
setChats([]);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setChats([]);
|
||||||
toast.error(error.response?.data?.detail || 'Failed to load chats');
|
toast.error(error.response?.data?.detail || 'Failed to load chats');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -232,8 +237,13 @@ const ChatManagementPage: React.FC = () => {
|
|||||||
const response = await chatService.getMessages(chatId);
|
const response = await chatService.getMessages(chatId);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setMessages(response.data);
|
setMessages(response.data);
|
||||||
|
} else {
|
||||||
|
// Clear data if response is not successful
|
||||||
|
setMessages([]);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setMessages([]);
|
||||||
toast.error(error.response?.data?.detail || 'Failed to load messages');
|
toast.error(error.response?.data?.detail || 'Failed to load messages');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMessages(false);
|
setLoadingMessages(false);
|
||||||
|
|||||||
@@ -73,12 +73,17 @@ const StaffDashboardPage: React.FC = () => {
|
|||||||
const response = await paymentService.getPayments({ page: 1, limit: 5 });
|
const response = await paymentService.getPayments({ page: 1, limit: 5 });
|
||||||
if (response.success && response.data?.payments) {
|
if (response.success && response.data?.payments) {
|
||||||
setRecentPayments(response.data.payments);
|
setRecentPayments(response.data.payments);
|
||||||
|
} else {
|
||||||
|
// Clear data if response is not successful
|
||||||
|
setRecentPayments([]);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Handle AbortError silently
|
// Handle AbortError silently
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setRecentPayments([]);
|
||||||
logger.error('Error fetching payments', err);
|
logger.error('Error fetching payments', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPayments(false);
|
setLoadingPayments(false);
|
||||||
@@ -109,12 +114,17 @@ const StaffDashboardPage: React.FC = () => {
|
|||||||
const response = await bookingService.getAllBookings({ page: 1, limit: 5 });
|
const response = await bookingService.getAllBookings({ page: 1, limit: 5 });
|
||||||
if ((response.status === 'success' || response.success) && response.data?.bookings) {
|
if ((response.status === 'success' || response.success) && response.data?.bookings) {
|
||||||
setRecentBookings(response.data.bookings);
|
setRecentBookings(response.data.bookings);
|
||||||
|
} else {
|
||||||
|
// Clear data if response is not successful
|
||||||
|
setRecentBookings([]);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Handle AbortError silently
|
// Handle AbortError silently
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Clear data when API connection fails
|
||||||
|
setRecentBookings([]);
|
||||||
logger.error('Error fetching bookings', err);
|
logger.error('Error fetching bookings', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingBookings(false);
|
setLoadingBookings(false);
|
||||||
|
|||||||
@@ -34,16 +34,22 @@ const Footer: React.FC = () => {
|
|||||||
const { settings } = useCompanySettings();
|
const { settings } = useCompanySettings();
|
||||||
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
||||||
const [enabledPages, setEnabledPages] = useState<Set<string>>(new Set());
|
const [enabledPages, setEnabledPages] = useState<Set<string>>(new Set());
|
||||||
|
const [apiError, setApiError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPageContent = async () => {
|
const fetchPageContent = async () => {
|
||||||
try {
|
try {
|
||||||
|
setApiError(false);
|
||||||
const response = await pageContentService.getFooterContent();
|
const response = await pageContentService.getFooterContent();
|
||||||
if (response.status === 'success' && response.data?.page_content) {
|
if (response.status === 'success' && response.data?.page_content) {
|
||||||
setPageContent(response.data.page_content);
|
setPageContent(response.data.page_content);
|
||||||
|
} else {
|
||||||
|
setPageContent(null);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error fetching footer content:', err);
|
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 displayPhone = settings.company_phone || null;
|
||||||
const displayEmail = settings.company_email || 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 phoneNumber = displayPhone ? displayPhone.replace(/\s+/g, '').replace(/[()]/g, '') : '';
|
||||||
const phoneHref = displayPhone ? 'tel:' + phoneNumber : '';
|
const phoneHref = displayPhone ? 'tel:' + phoneNumber : '';
|
||||||
|
|
||||||
@@ -134,13 +141,15 @@ const Footer: React.FC = () => {
|
|||||||
{ label: 'Contact Us', url: '/contact' }
|
{ 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
|
const quickLinks = pageContent?.footer_links?.quick_links && pageContent.footer_links.quick_links.length > 0
|
||||||
? pageContent.footer_links.quick_links
|
? pageContent.footer_links.quick_links
|
||||||
: defaultQuickLinks;
|
: (pageContent && !apiError ? defaultQuickLinks : []);
|
||||||
|
|
||||||
const allSupportLinks = pageContent?.footer_links?.support_links && pageContent.footer_links.support_links.length > 0
|
const allSupportLinks = pageContent?.footer_links?.support_links && pageContent.footer_links.support_links.length > 0
|
||||||
? pageContent.footer_links.support_links
|
? pageContent.footer_links.support_links
|
||||||
: defaultSupportLinks;
|
: (pageContent && !apiError ? defaultSupportLinks : []);
|
||||||
|
|
||||||
// Filter support links to only show enabled policy pages
|
// Filter support links to only show enabled policy pages
|
||||||
const supportLinks = allSupportLinks.filter((link) => {
|
const supportLinks = allSupportLinks.filter((link) => {
|
||||||
@@ -284,6 +293,7 @@ const Footer: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Links */}
|
{/* Quick Links */}
|
||||||
|
{quickLinks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
|
<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>
|
<span className="relative z-10">Quick Links</span>
|
||||||
@@ -303,8 +313,10 @@ const Footer: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Guest Services */}
|
{/* Guest Services */}
|
||||||
|
{supportLinks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
|
<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>
|
<span className="relative z-10">Guest Services</span>
|
||||||
@@ -324,6 +336,7 @@ const Footer: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contact Information */}
|
{/* Contact Information */}
|
||||||
<div>
|
<div>
|
||||||
@@ -331,7 +344,9 @@ const Footer: React.FC = () => {
|
|||||||
<span className="relative z-10">Contact</span>
|
<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>
|
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
|
||||||
</h3>
|
</h3>
|
||||||
|
{(displayAddress || displayPhone || displayEmail) && (
|
||||||
<ul className="space-y-5 sm:space-y-6">
|
<ul className="space-y-5 sm:space-y-6">
|
||||||
|
{displayAddress && (
|
||||||
<li className="flex items-start space-x-4 group">
|
<li className="flex items-start space-x-4 group">
|
||||||
<div className="relative mt-1 flex-shrink-0">
|
<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">
|
<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 className="absolute inset-0 bg-[#d4af37]/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||||
</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">
|
<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) => (
|
.split('\n').map((line, i) => (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
{line}
|
{line}
|
||||||
{i < displayAddress.split('\n').length - 1 && <br />}
|
{i < displayAddress.split('\n').length - 1 && <br />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
)}
|
||||||
{displayPhone && (
|
{displayPhone && (
|
||||||
<li className="flex items-center space-x-4 group">
|
<li className="flex items-center space-x-4 group">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
@@ -376,6 +392,7 @@ const Footer: React.FC = () => {
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Rating Section */}
|
{/* Rating Section */}
|
||||||
<div className="mt-8 sm:mt-10 pt-6 sm:pt-8 border-t border-gray-800/50">
|
<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 {
|
.prose.prose-invert a {
|
||||||
color: #d4af37 !important;
|
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