This commit is contained in:
Iliyan Angelov
2025-12-07 01:28:03 +02:00
parent 5a8ca3c475
commit 876af48145
31 changed files with 914 additions and 110 deletions

View File

@@ -12,7 +12,10 @@ from ...bookings.models.booking import Booking, BookingStatus
from ...shared.utils.role_helpers import can_manage_users
from ...shared.utils.response_helpers import success_response
from ...analytics.services.audit_service import audit_service
from ...shared.config.logging_config import get_logger
from ..schemas.user import CreateUserRequest, UpdateUserRequest
logger = get_logger(__name__)
router = APIRouter(prefix='/users', tags=['users'])
@router.get('/')
@@ -71,7 +74,31 @@ async def create_user(
password = user_data.password
full_name = user_data.full_name
phone_number = user_data.phone_number
role_id = user_data.role_id or 3 # Default to customer role
# Get customer role for default
customer_role = db.query(Role).filter(Role.name == 'customer').first()
if not customer_role:
raise HTTPException(status_code=500, detail='Customer role not found')
# Handle role - accept either role_id or role name
role_id = None
if user_data.role_id is not None:
role_id = user_data.role_id
elif user_data.role is not None:
# Convert role name to role_id
role_by_name = db.query(Role).filter(Role.name == user_data.role).first()
if not role_by_name:
raise HTTPException(status_code=400, detail=f'Invalid role name: {user_data.role}')
role_id = role_by_name.id
else:
# Default to customer role
role_id = customer_role.id
# Validate that the role exists
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=400, detail='Invalid role specified')
existing = db.query(User).filter(User.email == email).first()
if existing:
raise HTTPException(status_code=400, detail='Email already exists')
@@ -186,18 +213,32 @@ async def update_user(
user.email = user_data.email
if user_data.phone_number is not None:
user.phone = user_data.phone_number
if user_data.role_id is not None and can_manage_users(current_user, db):
# Handle role update - accept either role_id or role name
role_id_to_set = None
if user_data.role_id is not None:
role_id_to_set = user_data.role_id
elif user_data.role is not None:
# Convert role name to role_id
role_by_name = db.query(Role).filter(Role.name == user_data.role).first()
if not role_by_name:
raise HTTPException(status_code=400, detail=f'Invalid role name: {user_data.role}')
role_id_to_set = role_by_name.id
if role_id_to_set is not None and can_manage_users(current_user, db):
# SECURITY: Prevent admin from changing their own role
if current_user.id == id:
raise HTTPException(
status_code=400,
detail='You cannot change your own role. Please ask another admin to do it.'
)
new_role = db.query(Role).filter(Role.id == user_data.role_id).first()
new_role_name = new_role.name if new_role else None
if user_data.role_id != old_role_id:
changes['role'] = {'old': old_role_name, 'new': new_role_name, 'old_id': old_role_id, 'new_id': user_data.role_id}
user.role_id = user_data.role_id
new_role = db.query(Role).filter(Role.id == role_id_to_set).first()
if not new_role:
raise HTTPException(status_code=400, detail='Invalid role ID specified')
new_role_name = new_role.name
if role_id_to_set != old_role_id:
changes['role'] = {'old': old_role_name, 'new': new_role_name, 'old_id': old_role_id, 'new_id': role_id_to_set}
user.role_id = role_id_to_set
if user_data.is_active is not None and can_manage_users(current_user, db):
if user_data.is_active != old_is_active:
changes['is_active'] = {'old': old_is_active, 'new': user_data.is_active}
@@ -260,8 +301,6 @@ async def update_user(
status='success'
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Failed to log user update audit: {e}')
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active}
@@ -304,10 +343,7 @@ async def delete_user(id: int, request: Request, current_user: User=Depends(auth
if active_bookings > 0:
raise HTTPException(status_code=400, detail='Cannot delete user with active bookings')
db.delete(user)
db.commit()
# SECURITY: Log user deletion for audit trail
# Log user deletion BEFORE deletion (so we can reference the user)
try:
await audit_service.log_action(
db=db,
@@ -322,13 +358,49 @@ async def delete_user(id: int, request: Request, current_user: User=Depends(auth
status='success'
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Failed to log user deletion audit: {e}')
# Handle foreign key constraints: Anonymize audit logs before deletion
# This prevents foreign key constraint errors while preserving audit trail
try:
from ...analytics.models.audit_log import AuditLog
audit_logs = db.query(AuditLog).filter(AuditLog.user_id == id).all()
for log in audit_logs:
# Set user_id to None to break foreign key constraint
# This anonymizes the logs while keeping them for security monitoring
log.user_id = None
if audit_logs:
db.flush() # Flush changes before deleting user
logger.info(f'Anonymized {len(audit_logs)} audit logs for user {id} before deletion')
except Exception as e:
logger.warning(f'Could not anonymize audit logs for user {id}: {str(e)}')
# Continue with deletion attempt - if it fails, we'll catch the constraint error
# Delete the user
try:
db.delete(user)
db.commit()
except Exception as delete_error:
db.rollback()
error_msg = str(delete_error)
# Check for foreign key constraint errors
if 'foreign key' in error_msg.lower() or 'constraint' in error_msg.lower() or '1451' in error_msg:
logger.error(f'Foreign key constraint error when deleting user {id}: {error_msg}')
raise HTTPException(
status_code=400,
detail='Cannot delete user: User has associated records (bookings, payments, audit logs, etc.) that prevent deletion. Please deactivate the user instead.'
)
else:
logger.error(f'Error deleting user {id}: {error_msg}', exc_info=True)
raise HTTPException(
status_code=500,
detail=f'Error deleting user: {error_msg}'
)
return success_response(message='User deleted successfully')
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
logger.error(f'Unexpected error deleting user {id}: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=f'Error deleting user: {str(e)}')

View File

@@ -12,6 +12,7 @@ class CreateUserRequest(BaseModel):
password: str = Field(..., min_length=8, description="Password")
phone_number: Optional[str] = Field(None, max_length=20, description="Phone number")
role_id: Optional[int] = Field(None, gt=0, description="Role ID")
role: Optional[str] = Field(None, description="Role name (alternative to role_id)")
@field_validator('password')
@classmethod
@@ -34,5 +35,6 @@ class UpdateUserRequest(BaseModel):
email: Optional[EmailStr] = None
phone_number: Optional[str] = Field(None, max_length=20)
role_id: Optional[int] = Field(None, gt=0)
role: Optional[str] = Field(None, description="Role name (alternative to role_id)")
is_active: Optional[bool] = None

View File

@@ -46,7 +46,7 @@ async def get_guest_requests(
priority: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get guest requests with filtering"""
@@ -57,17 +57,22 @@ async def get_guest_requests(
joinedload(GuestRequest.guest)
)
# Check if user is housekeeping - they can only see requests assigned to them or unassigned
# Check user role to determine access level
role = db.query(Role).filter(Role.id == current_user.role_id).first()
is_housekeeping = role and role.name == 'housekeeping'
role_name = role.name if role else 'customer'
if is_housekeeping:
# Customers can only see their own requests
if role_name == 'customer':
query = query.filter(GuestRequest.user_id == current_user.id)
# Housekeeping can only see requests assigned to them or unassigned
elif role_name == 'housekeeping':
query = query.filter(
or_(
GuestRequest.assigned_to == current_user.id,
GuestRequest.assigned_to.is_(None)
)
)
# Admin and staff can see all requests (no additional filter needed)
if status:
query = query.filter(GuestRequest.status == status)
@@ -379,7 +384,7 @@ async def update_guest_request(
@router.get('/{request_id}')
async def get_guest_request(
request_id: int,
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get a single guest request"""
@@ -397,10 +402,17 @@ async def get_guest_request(
# Check permissions
role = db.query(Role).filter(Role.id == current_user.role_id).first()
is_housekeeping = role and role.name == 'housekeeping'
role_name = role.name if role else 'customer'
if is_housekeeping and request.assigned_to and request.assigned_to != current_user.id:
raise HTTPException(status_code=403, detail='You can only view requests assigned to you')
# Customers can only view their own requests
if role_name == 'customer':
if request.user_id != current_user.id:
raise HTTPException(status_code=403, detail='You can only view your own requests')
# Housekeeping can only view requests assigned to them or unassigned
elif role_name == 'housekeeping':
if request.assigned_to and request.assigned_to != current_user.id:
raise HTTPException(status_code=403, detail='You can only view requests assigned to you')
# Admin and staff can view all requests (no additional check needed)
return {
'status': 'success',

View File

@@ -39,6 +39,9 @@ async def create_api_key(
):
"""Create a new API key."""
try:
from sqlalchemy.exc import ProgrammingError
import pymysql
expires_at = None
if key_data.expires_at:
expires_at = datetime.fromisoformat(key_data.expires_at.replace('Z', '+00:00'))
@@ -67,6 +70,17 @@ async def create_api_key(
},
message='API key created successfully. Save this key securely - it will not be shown again.'
)
except HTTPException:
raise
except (ProgrammingError, pymysql.err.ProgrammingError) as e:
error_str = str(e).lower()
if "doesn't exist" in error_str or "does not exist" in error_str or "table" in error_str and "not found" in error_str:
logger.warning(f'API keys table does not exist: {str(e)}')
raise HTTPException(
status_code=503,
detail='API keys table not found. Please run database migrations to create the table.'
)
raise
except Exception as e:
logger.error(f'Error creating API key: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@@ -78,6 +92,9 @@ async def get_api_keys(
):
"""Get all API keys."""
try:
from sqlalchemy.exc import ProgrammingError
import pymysql
api_keys = db.query(APIKey).order_by(APIKey.created_at.desc()).all()
return success_response(data={
@@ -93,6 +110,12 @@ async def get_api_keys(
'created_at': k.created_at.isoformat() if k.created_at else None
} for k in api_keys]
})
except (ProgrammingError, pymysql.err.ProgrammingError) as e:
error_str = str(e).lower()
if "doesn't exist" in error_str or "does not exist" in error_str or "table" in error_str and "not found" in error_str:
logger.warning(f'API keys table does not exist: {str(e)}')
return success_response(data={'api_keys': []}, message='API keys table not found. Please run database migrations.')
raise
except Exception as e:
logger.error(f'Error getting API keys: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -65,18 +65,30 @@ async def get_webhooks(
):
"""Get all webhooks."""
try:
webhooks = db.query(Webhook).order_by(Webhook.created_at.desc()).all()
from sqlalchemy.orm import noload
# Explicitly prevent relationship loading which might be causing the SQL error
webhooks = db.query(Webhook).options(noload(Webhook.creator)).order_by(Webhook.created_at.desc()).all()
return success_response(data={
'webhooks': [{
'id': w.id,
'name': w.name,
'url': w.url,
'events': w.events,
'status': w.status.value,
'created_at': w.created_at.isoformat() if w.created_at else None
} for w in webhooks]
})
result = []
for w in webhooks:
try:
result.append({
'id': w.id,
'name': w.name,
'url': w.url,
'events': w.events if w.events else [],
'status': w.status.value if w.status else 'inactive',
'created_at': w.created_at.isoformat() if w.created_at else None,
'updated_at': w.updated_at.isoformat() if w.updated_at else None,
'description': w.description,
'retry_count': w.retry_count,
'timeout_seconds': w.timeout_seconds
})
except Exception as e:
logger.error(f'Error serializing webhook {w.id}: {str(e)}', exc_info=True)
continue
return success_response(data={'webhooks': result})
except Exception as e:
logger.error(f'Error getting webhooks: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -105,6 +105,10 @@ from ..notifications.models.email_campaign import Campaign, CampaignStatus, Camp
from ..security.models.security_event import SecurityEvent, SecurityEventType, SecurityEventSeverity, IPWhitelist, IPBlacklist, OAuthProvider, OAuthToken
from ..security.models.gdpr_compliance import DataSubjectRequest, DataSubjectRequestType, DataSubjectRequestStatus, DataRetentionPolicy, ConsentRecord
# Integration models
from ..integrations.models.api_key import APIKey
from ..integrations.models.webhook import Webhook, WebhookDelivery, WebhookEventType, WebhookStatus, WebhookDeliveryStatus
__all__ = [
# Auth
'Role', 'User', 'RefreshToken', 'PasswordResetToken',
@@ -155,4 +159,6 @@ __all__ = [
# Security
'SecurityEvent', 'SecurityEventType', 'SecurityEventSeverity', 'IPWhitelist', 'IPBlacklist', 'OAuthProvider', 'OAuthToken',
'DataSubjectRequest', 'DataSubjectRequestType', 'DataSubjectRequestStatus', 'DataRetentionPolicy', 'ConsentRecord',
# Integrations
'APIKey', 'Webhook', 'WebhookDelivery', 'WebhookEventType', 'WebhookStatus', 'WebhookDeliveryStatus',
]

View File

@@ -184,7 +184,7 @@ def serialize_channel(channel: TeamChannel, current_user_id: int, unread_count:
"id": m.id,
"full_name": m.full_name,
"email": m.email,
"avatar_url": m.avatar_url,
"avatar_url": m.avatar, # User model uses 'avatar' field
"role": m.role.name if m.role else None
}
for m in channel.members
@@ -202,7 +202,7 @@ def serialize_message(message: TeamMessage) -> dict:
"sender": {
"id": message.sender.id,
"full_name": message.sender.full_name,
"avatar_url": message.sender.avatar_url,
"avatar_url": message.sender.avatar, # User model uses 'avatar' field
"role": message.sender.role.name if message.sender.role else None
} if message.sender else None,
"content": message.content if not message.is_deleted else "[Message deleted]",
@@ -629,7 +629,7 @@ async def send_direct_message(
"sender": {
"id": current_user.id,
"full_name": current_user.full_name,
"avatar_url": current_user.avatar_url
"avatar_url": current_user.avatar # User model uses 'avatar' field
},
"message": serialized
}
@@ -647,20 +647,55 @@ async def get_team_users(
db: Session = Depends(get_db)
):
"""Get all team users (admin, staff, housekeeping) for messaging."""
# Get role IDs for team roles to ensure proper filtering
team_roles = db.query(Role).filter(
Role.name.in_(['admin', 'staff', 'housekeeping'])
).all()
team_role_ids = [r.id for r in team_roles]
if not team_role_ids:
logger.error('No team roles found in database')
return {"success": True, "data": []}
# Build query with proper role filtering
query = db.query(User).options(
joinedload(User.role),
joinedload(User.presence)
).join(Role).filter(
Role.name.in_(['admin', 'staff', 'housekeeping']),
).filter(
User.role_id.in_(team_role_ids),
User.is_active == True,
User.id != current_user.id
)
if role:
query = query.filter(Role.name == role)
role_obj = db.query(Role).filter(Role.name == role).first()
if role_obj:
query = query.filter(User.role_id == role_obj.id)
users = query.order_by(User.full_name).all()
# Debug: Check total count of team users (including inactive) for troubleshooting
if not users:
# Check if there are any team users at all (including inactive)
all_team_users = db.query(User).filter(
User.role_id.in_(team_role_ids),
User.id != current_user.id
).count()
active_team_users = db.query(User).filter(
User.role_id.in_(team_role_ids),
User.is_active == True,
User.id != current_user.id
).count()
logger.warning(
f'No active team users found for user {current_user.id} ({current_user.email}). '
f'Total team users (including inactive): {all_team_users}, '
f'Active team users: {active_team_users}. '
f'Query filters: role={role}, is_active=True, excluded_user_id={current_user.id}, '
f'team_role_ids={team_role_ids}'
)
return {
"success": True,
"data": [
@@ -668,10 +703,10 @@ async def get_team_users(
"id": u.id,
"full_name": u.full_name,
"email": u.email,
"avatar_url": u.avatar_url,
"avatar_url": u.avatar, # User model uses 'avatar' field
"role": u.role.name if u.role else None,
"status": u.presence.status if u.presence else 'offline',
"last_seen": u.presence.last_seen_at.isoformat() if u.presence else None
"last_seen": u.presence.last_seen_at.isoformat() if u.presence and u.presence.last_seen_at else None
}
for u in users
]

View File

@@ -26,6 +26,8 @@ async def verify_step_up(
):
"""Verify step-up authentication (MFA token or password re-entry)."""
try:
from ..models.accountant_session import AccountantSession
mfa_token = step_up_data.get('mfa_token')
password = step_up_data.get('password')
session_token = step_up_data.get('session_token')
@@ -34,8 +36,18 @@ async def verify_step_up(
# Try to get from header or cookie
session_token = request.headers.get('X-Session-Token') or request.cookies.get('session_token')
# If still no session token, try to find the most recent active session for this user
if not session_token:
raise HTTPException(status_code=400, detail='Session token is required')
active_session = db.query(AccountantSession).filter(
AccountantSession.user_id == current_user.id,
AccountantSession.is_active == True,
AccountantSession.expires_at > datetime.utcnow()
).order_by(AccountantSession.last_activity.desc()).first()
if active_session:
session_token = active_session.session_token
else:
raise HTTPException(status_code=400, detail='No active session found. Please log in again.')
# Verify MFA if token provided
if mfa_token:

View File

@@ -126,8 +126,18 @@ class AccountantSecurityService:
Check if step-up authentication is required.
Returns (requires_step_up: bool, reason: str | None)
"""
# If no session token provided, try to find the most recent active session for this user
if not session_token:
return True, "Step-up authentication required for this action"
active_session = db.query(AccountantSession).filter(
AccountantSession.user_id == user_id,
AccountantSession.is_active == True,
AccountantSession.expires_at > datetime.utcnow()
).order_by(AccountantSession.last_activity.desc()).first()
if active_session:
session_token = active_session.session_token
else:
return True, "Step-up authentication required for this action"
session = AccountantSecurityService.validate_session(db, session_token, update_activity=False)
if not session:
@@ -167,6 +177,8 @@ class AccountantSecurityService:
minutes=AccountantSecurityService.STEP_UP_VALIDITY_MINUTES
)
# Use flush to ensure changes are visible in the same transaction
# The route handler will commit
db.flush()
return True

View File

@@ -21,8 +21,9 @@ async def get_favorites(current_user: User=Depends(get_current_user), db: Sessio
role = db.query(Role).filter(Role.id == current_user.role_id).first()
role_name = role.name if role else 'customer'
if role_name in ['admin', 'staff', 'accountant']:
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot have favorites')
# Only customers can have favorites
if role_name != 'customer':
raise HTTPException(status_code=403, detail='Only customers can have favorites')
try:
favorites = db.query(Favorite).filter(Favorite.user_id == current_user.id).order_by(Favorite.created_at.desc()).all()
result = []
@@ -50,8 +51,9 @@ async def add_favorite(room_id: int, current_user: User=Depends(get_current_user
role = db.query(Role).filter(Role.id == current_user.role_id).first()
role_name = role.name if role else 'customer'
if role_name in ['admin', 'staff', 'accountant']:
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot add favorites')
# Only customers can add favorites
if role_name != 'customer':
raise HTTPException(status_code=403, detail='Only customers can add favorites')
try:
room = db.query(Room).filter(Room.id == room_id).first()
if not room:
@@ -80,8 +82,9 @@ async def remove_favorite(room_id: int, current_user: User=Depends(get_current_u
role = db.query(Role).filter(Role.id == current_user.role_id).first()
role_name = role.name if role else 'customer'
if role_name in ['admin', 'staff', 'accountant']:
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot remove favorites')
# Only customers can remove favorites
if role_name != 'customer':
raise HTTPException(status_code=403, detail='Only customers can remove favorites')
try:
favorite = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first()
if not favorite:
@@ -105,7 +108,8 @@ async def check_favorite(room_id: int, current_user: User=Depends(get_current_us
role = db.query(Role).filter(Role.id == current_user.role_id).first()
role_name = role.name if role else 'customer'
if role_name in ['admin', 'staff', 'accountant']:
# Only customers can have favorites
if role_name != 'customer':
return {'status': 'success', 'data': {'isFavorited': False}}
try:
favorite = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first()

View File

@@ -24,6 +24,7 @@ import Loading from './shared/components/Loading';
import Preloader from './shared/components/Preloader';
import ScrollToTop from './shared/components/ScrollToTop';
import AuthModalManager from './features/auth/components/AuthModalManager';
import StepUpAuthManager from './features/auth/components/StepUpAuthManager';
import ResetPasswordRouteHandler from './features/auth/components/ResetPasswordRouteHandler';
import ErrorBoundaryRoute from './shared/components/ErrorBoundaryRoute';
@@ -41,6 +42,7 @@ import {
CustomerRoute,
HousekeepingRoute
} from './features/auth/components';
import { StepUpAuthProvider } from './features/auth/contexts/StepUpAuthContext';
const HomePage = lazy(() => import('./features/content/pages/HomePage'));
const DashboardPage = lazy(() => import('./pages/customer/DashboardPage'));
@@ -250,7 +252,8 @@ function App() {
<CompanySettingsProvider>
<AntibotProvider>
<AuthModalProvider>
<RoomProvider>
<StepUpAuthProvider>
<RoomProvider>
<BrowserRouter
future={{
v7_startTransition: true,
@@ -496,6 +499,16 @@ function App() {
</ErrorBoundaryRoute>
}
/>
<Route
path="guest-requests"
element={
<ErrorBoundaryRoute>
<CustomerRoute>
<GuestRequestsPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="gdpr"
element={
@@ -953,9 +966,11 @@ function App() {
<CookiePreferencesModal />
<AnalyticsLoader />
<AuthModalManager />
<StepUpAuthManager />
</Suspense>
</BrowserRouter>
</RoomProvider>
</RoomProvider>
</StepUpAuthProvider>
</AuthModalProvider>
</AntibotProvider>
</CompanySettingsProvider>

View File

@@ -0,0 +1,57 @@
import React, { useEffect, useRef } from 'react';
import { useStepUpAuth } from '../contexts/StepUpAuthContext';
import StepUpAuthModal from './StepUpAuthModal';
// Store reference to context functions for event listener
let stepUpContextRef: { openStepUp: (action: string, request: () => Promise<any>) => void } | null = null;
const StepUpAuthManager: React.FC = () => {
const { isOpen, actionDescription, closeStepUp, onStepUpSuccess, openStepUp } = useStepUpAuth();
const contextRef = useRef({ openStepUp });
// Update ref when context changes
useEffect(() => {
contextRef.current = { openStepUp };
stepUpContextRef = { openStepUp };
}, [openStepUp]);
// Listen for step-up required events from API client
useEffect(() => {
const handleStepUpRequired = (event: CustomEvent) => {
console.log('Step-up required event received', event.detail);
const { action, originalRequest } = event.detail;
// Store the original request config for retry
const retryRequest = async () => {
if (originalRequest && typeof originalRequest === 'function') {
return await originalRequest();
}
};
// Open step-up modal with the pending request
if (stepUpContextRef && stepUpContextRef.openStepUp) {
console.log('Opening step-up modal', { action: action || 'this action' });
stepUpContextRef.openStepUp(action || 'this action', retryRequest);
} else {
console.warn('StepUpAuthContext ref not available');
}
};
window.addEventListener('auth:step-up-required', handleStepUpRequired as EventListener);
return () => {
window.removeEventListener('auth:step-up-required', handleStepUpRequired as EventListener);
};
}, []);
return (
<StepUpAuthModal
isOpen={isOpen}
onClose={closeStepUp}
onSuccess={onStepUpSuccess}
actionDescription={actionDescription}
/>
);
};
export default StepUpAuthManager;

View File

@@ -0,0 +1,335 @@
import React, { useState, useEffect } from 'react';
import { X, Shield, Lock, KeyRound } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { toast } from 'react-toastify';
import accountantSecurityService from '../../security/services/accountantSecurityService';
import useAuthStore from '../../../store/useAuthStore';
const mfaTokenSchema = yup.object({
mfaToken: yup
.string()
.required('MFA token is required')
.matches(/^\d{6}$/, 'MFA token must be 6 digits'),
});
const passwordSchema = yup.object({
password: yup
.string()
.required('Password is required')
.min(6, 'Password must be at least 6 characters'),
});
type MFATokenFormData = yup.InferType<typeof mfaTokenSchema>;
type PasswordFormData = yup.InferType<typeof passwordSchema>;
interface StepUpAuthModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
actionDescription?: string;
}
const StepUpAuthModal: React.FC<StepUpAuthModalProps> = ({
isOpen,
onClose,
onSuccess,
actionDescription = 'this action',
}) => {
const { userInfo } = useAuthStore();
const [verificationMethod, setVerificationMethod] = useState<'mfa' | 'password'>('mfa');
const [isVerifying, setIsVerifying] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register: registerMFA,
handleSubmit: handleSubmitMFA,
formState: { errors: mfaErrors },
reset: resetMFA,
} = useForm<MFATokenFormData>({
resolver: yupResolver(mfaTokenSchema),
defaultValues: {
mfaToken: '',
},
});
const {
register: registerPassword,
handleSubmit: handleSubmitPassword,
formState: { errors: passwordErrors },
reset: resetPassword,
} = useForm<PasswordFormData>({
resolver: yupResolver(passwordSchema),
defaultValues: {
password: '',
},
});
useEffect(() => {
if (isOpen) {
setError(null);
resetMFA();
resetPassword();
// Default to MFA if user has it enabled, otherwise password
// You can check userInfo.mfa_enabled if available
setVerificationMethod('mfa');
}
}, [isOpen, resetMFA, resetPassword]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen && !isVerifying) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, isVerifying, onClose]);
const onSubmitMFA = async (data: MFATokenFormData) => {
try {
setIsVerifying(true);
setError(null);
const response = await accountantSecurityService.verifyStepUp({
mfa_token: data.mfaToken,
});
if (response.status === 'success' && response.data.step_up_completed) {
toast.success('Identity verified successfully');
// Small delay to ensure backend commit is complete before retrying
await new Promise(resolve => setTimeout(resolve, 100));
onSuccess();
onClose();
} else {
throw new Error('Step-up verification failed');
}
} catch (error: any) {
const errorMessage =
error.response?.data?.detail || error.response?.data?.message || 'Failed to verify identity. Please try again.';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsVerifying(false);
}
};
const onSubmitPassword = async (data: PasswordFormData) => {
try {
setIsVerifying(true);
setError(null);
const response = await accountantSecurityService.verifyStepUp({
password: data.password,
});
if (response.status === 'success' && response.data.step_up_completed) {
toast.success('Identity verified successfully');
// Small delay to ensure backend commit is complete before retrying
await new Promise(resolve => setTimeout(resolve, 100));
onSuccess();
onClose();
} else {
throw new Error('Step-up verification failed');
}
} catch (error: any) {
const errorMessage =
error.response?.data?.detail || error.response?.data?.message || 'Invalid password. Please try again.';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsVerifying(false);
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center p-3 sm:p-4 md:p-6"
onClick={(e) => {
if (e.target === e.currentTarget && !isVerifying) {
onClose();
}
}}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* Modal */}
<div className="relative w-full max-w-md max-h-[95vh] overflow-y-auto bg-gradient-to-br from-gray-50 via-white to-gray-50 rounded-lg shadow-2xl border border-amber-200">
{/* Close button */}
{!isVerifying && (
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 p-2 rounded-full hover:bg-gray-100 transition-colors text-gray-500 hover:text-gray-900"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
)}
<div className="p-4 sm:p-6 lg:p-8">
{/* Header */}
<div className="text-center mb-6">
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-amber-400 to-amber-600 rounded-full flex items-center justify-center mb-4">
<Shield className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Verify Your Identity</h2>
<p className="text-gray-600 text-sm">
Step-up authentication is required for <span className="font-semibold">{actionDescription}</span>
</p>
</div>
{/* Error message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
)}
{/* Verification method selector */}
<div className="mb-6 flex gap-2 p-1 bg-gray-100 rounded-lg">
<button
type="button"
onClick={() => {
if (!isVerifying) {
setVerificationMethod('mfa');
setError(null);
resetMFA();
}
}}
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
verificationMethod === 'mfa'
? 'bg-white text-amber-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
} ${isVerifying ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={isVerifying}
>
<div className="flex items-center justify-center gap-2">
<KeyRound className="w-4 h-4" />
MFA Token
</div>
</button>
<button
type="button"
onClick={() => {
if (!isVerifying) {
setVerificationMethod('password');
setError(null);
resetPassword();
}
}}
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
verificationMethod === 'password'
? 'bg-white text-amber-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
} ${isVerifying ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={isVerifying}
>
<div className="flex items-center justify-center gap-2">
<Lock className="w-4 h-4" />
Password
</div>
</button>
</div>
{/* MFA Form */}
{verificationMethod === 'mfa' && (
<form onSubmit={handleSubmitMFA(onSubmitMFA)} className="space-y-4">
<div>
<label htmlFor="mfaToken" className="block text-sm font-medium text-gray-700 mb-2">
Enter 6-digit MFA code
</label>
<input
{...registerMFA('mfaToken')}
type="text"
id="mfaToken"
maxLength={6}
placeholder="000000"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 text-center text-2xl tracking-widest font-mono"
disabled={isVerifying}
autoComplete="one-time-code"
/>
{mfaErrors.mfaToken && (
<p className="mt-1 text-sm text-red-600">{mfaErrors.mfaToken.message}</p>
)}
<p className="mt-2 text-xs text-gray-500">Enter the 6-digit code from your authenticator app</p>
</div>
<button
type="submit"
disabled={isVerifying}
className="w-full py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isVerifying ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Verifying...
</>
) : (
<>
<Shield className="w-5 h-5" />
Verify Identity
</>
)}
</button>
</form>
)}
{/* Password Form */}
{verificationMethod === 'password' && (
<form onSubmit={handleSubmitPassword(onSubmitPassword)} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Re-enter your password
</label>
<input
{...registerPassword('password')}
type="password"
id="password"
placeholder="Enter your password"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
disabled={isVerifying}
autoComplete="current-password"
/>
{passwordErrors.password && (
<p className="mt-1 text-sm text-red-600">{passwordErrors.password.message}</p>
)}
<p className="mt-2 text-xs text-gray-500">Enter your password to verify your identity</p>
</div>
<button
type="submit"
disabled={isVerifying}
className="w-full py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isVerifying ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Verifying...
</>
) : (
<>
<Shield className="w-5 h-5" />
Verify Identity
</>
)}
</button>
</form>
)}
{/* Info */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-xs text-blue-800">
<strong>Note:</strong> Step-up authentication is valid for 15 minutes. You won't need to verify again for
similar actions during this time.
</p>
</div>
</div>
</div>
</div>
);
};
export default StepUpAuthModal;

View File

@@ -0,0 +1,67 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
interface StepUpAuthContextType {
isOpen: boolean;
actionDescription: string;
pendingRequest: (() => Promise<any>) | null;
openStepUp: (actionDescription: string, pendingRequest: () => Promise<any>) => void;
closeStepUp: () => void;
onStepUpSuccess: () => void;
}
const StepUpAuthContext = createContext<StepUpAuthContextType | undefined>(undefined);
export const StepUpAuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [actionDescription, setActionDescription] = useState('');
const [pendingRequest, setPendingRequest] = useState<(() => Promise<any>) | null>(null);
const openStepUp = useCallback((action: string, request: () => Promise<any>) => {
console.log('openStepUp called', { action, hasRequest: !!request });
setActionDescription(action);
setPendingRequest(() => request);
setIsOpen(true);
}, []);
const closeStepUp = useCallback(() => {
setIsOpen(false);
setActionDescription('');
setPendingRequest(null);
}, []);
const onStepUpSuccess = useCallback(async () => {
if (pendingRequest) {
try {
await pendingRequest();
} catch (error) {
// Error will be handled by the original request handler
console.error('Error retrying request after step-up:', error);
}
}
closeStepUp();
}, [pendingRequest, closeStepUp]);
return (
<StepUpAuthContext.Provider
value={{
isOpen,
actionDescription,
pendingRequest,
openStepUp,
closeStepUp,
onStepUpSuccess,
}}
>
{children}
</StepUpAuthContext.Provider>
);
};
export const useStepUpAuth = () => {
const context = useContext(StepUpAuthContext);
if (context === undefined) {
throw new Error('useStepUpAuth must be used within a StepUpAuthProvider');
}
return context;
};

View File

@@ -2,6 +2,8 @@ import React, { useState } from 'react';
import { Heart } from 'lucide-react';
import useFavoritesStore from '../../../store/useFavoritesStore';
import useAuthStore from '../../../store/useAuthStore';
import { useAuthModal } from '../../../features/auth/contexts/AuthModalContext';
import { toast } from 'react-toastify';
interface FavoriteButtonProps {
roomId: number;
@@ -16,7 +18,8 @@ const FavoriteButton: React.FC<FavoriteButtonProps> = ({
showTooltip = true,
className = '',
}) => {
const { userInfo } = useAuthStore();
const { userInfo, isAuthenticated } = useAuthStore();
const { openModal } = useAuthModal();
const {
isFavorited,
addToFavorites,
@@ -26,6 +29,7 @@ const FavoriteButton: React.FC<FavoriteButtonProps> = ({
const [showTooltipText, setShowTooltipText] =
useState(false);
// Hide button for admin, staff, accountant
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
return null;
}
@@ -53,6 +57,13 @@ const FavoriteButton: React.FC<FavoriteButtonProps> = ({
if (isProcessing) return;
// Require authentication for adding/removing favorites
if (!isAuthenticated || userInfo?.role !== 'customer') {
toast.info('Please login as a customer to add favorites');
openModal('login');
return;
}
setIsProcessing(true);
try {
if (favorited) {

View File

@@ -51,6 +51,12 @@ const TeamChatPage: React.FC<TeamChatPageProps> = ({ role }) => {
const [editContent, setEditContent] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const [ws, setWs] = useState<WebSocket | null>(null);
const selectedChannelRef = useRef<TeamChannel | null>(null);
// Keep ref in sync with state
useEffect(() => {
selectedChannelRef.current = selectedChannel;
}, [selectedChannel]);
// Fetch channels
const fetchChannels = useCallback(async () => {
@@ -96,28 +102,43 @@ const TeamChatPage: React.FC<TeamChatPageProps> = ({ role }) => {
// Initialize WebSocket
useEffect(() => {
if (!userInfo?.id) return;
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/v1/team-chat/ws`;
const socket = new WebSocket(wsUrl);
socket.onopen = () => {
// Send authentication
socket.send(JSON.stringify({ type: 'auth', user_id: userInfo?.id }));
// Send authentication only when socket is open
if (socket.readyState === WebSocket.OPEN) {
try {
socket.send(JSON.stringify({ type: 'auth', user_id: userInfo.id }));
} catch (error) {
console.error('Error sending WebSocket auth:', error);
}
}
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
try {
const data = JSON.parse(event.data);
if (data.type === 'new_message' && data.data.channel_id === selectedChannel?.id) {
setMessages(prev => [...prev, data.data]);
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
} else if (data.type === 'new_message_notification') {
// Show notification for messages in other channels
toast.info(`New message in ${data.data.channel_name || 'Team Chat'}`);
fetchChannels(); // Refresh unread counts
} else if (data.type === 'message_edited') {
setMessages(prev => prev.map(m => m.id === data.data.id ? data.data : m));
} else if (data.type === 'message_deleted') {
setMessages(prev => prev.filter(m => m.id !== data.data.id));
// Use ref to get current selectedChannel without causing re-renders
const currentChannel = selectedChannelRef.current;
if (data.type === 'new_message' && data.data.channel_id === currentChannel?.id) {
setMessages(prev => [...prev, data.data]);
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
} else if (data.type === 'new_message_notification') {
// Show notification for messages in other channels
toast.info(`New message in ${data.data.channel_name || 'Team Chat'}`);
fetchChannels(); // Refresh unread counts
} else if (data.type === 'message_edited') {
setMessages(prev => prev.map(m => m.id === data.data.id ? data.data : m));
} else if (data.type === 'message_deleted') {
setMessages(prev => prev.filter(m => m.id !== data.data.id));
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
@@ -125,12 +146,19 @@ const TeamChatPage: React.FC<TeamChatPageProps> = ({ role }) => {
console.error('WebSocket error:', error);
};
socket.onclose = () => {
console.log('WebSocket closed');
};
setWs(socket);
return () => {
socket.close();
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
socket.close();
}
setWs(null);
};
}, [userInfo?.id, selectedChannel?.id, fetchChannels]);
}, [userInfo?.id]); // Removed selectedChannel?.id and fetchChannels from dependencies
// Initial data load
useEffect(() => {
@@ -146,8 +174,14 @@ const TeamChatPage: React.FC<TeamChatPageProps> = ({ role }) => {
useEffect(() => {
if (selectedChannel) {
fetchMessages(selectedChannel.id);
// Join channel in WebSocket
ws?.send(JSON.stringify({ type: 'join_channel', channel_id: selectedChannel.id }));
// Join channel in WebSocket - only if socket is open
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'join_channel', channel_id: selectedChannel.id }));
} catch (error) {
console.error('Error joining channel via WebSocket:', error);
}
}
}
}, [selectedChannel, fetchMessages, ws]);

View File

@@ -9,15 +9,18 @@ import Pagination from '../../shared/components/Pagination';
import useAuthStore from '../../store/useAuthStore';
import { logger } from '../../shared/utils/logger';
import { useApiCall } from '../../shared/hooks/useApiCall';
import { useStepUpAuth } from '../../features/auth/contexts/StepUpAuthContext';
const UserManagementPage: React.FC = () => {
const { userInfo } = useAuthStore();
const { openStepUp } = useStepUpAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deletingUserId, setDeletingUserId] = useState<number | null>(null);
const pendingSubmitDataRef = useRef<{ data: any; isEdit: boolean } | null>(null);
const { execute: executeSubmit, isLoading: isSubmitting } = useApiCall(
async (data: any, isEdit: boolean) => {
@@ -113,28 +116,84 @@ const UserManagementPage: React.FC = () => {
return;
}
try {
const submitData: any = {
full_name: formData.full_name,
email: formData.email,
phone_number: formData.phone_number,
role: formData.role,
status: formData.status,
};
const submitData: any = {
full_name: formData.full_name,
email: formData.email,
phone_number: formData.phone_number,
role: formData.role,
status: formData.status,
};
if (editingUser) {
if (formData.password && formData.password.trim() !== '') {
submitData.password = formData.password;
}
logger.debug('Updating user', { userId: editingUser.id, updateData: submitData });
} else {
if (editingUser) {
if (formData.password && formData.password.trim() !== '') {
submitData.password = formData.password;
logger.debug('Creating user', { formData: submitData });
}
logger.debug('Updating user', { userId: editingUser.id, updateData: submitData });
} else {
submitData.password = formData.password;
logger.debug('Creating user', { formData: submitData });
}
// Store data for retry after step-up
pendingSubmitDataRef.current = { data: submitData, isEdit: !!editingUser };
try {
await executeSubmit(submitData, !!editingUser);
} catch (error: any) {
logger.error('Error submitting user', error);
// Check if step-up authentication is required
// Check both the original response structure and the modified error from API client
const errorData = error.response?.data;
const errorDetail = errorData?.detail;
// Check for step-up required in multiple ways
const isStepUpRequired =
error.requiresStepUp === true ||
error.stepUpAction !== undefined ||
(error.response?.status === 403 &&
(errorDetail?.error === 'step_up_required' ||
errorData?.error === 'step_up_required' ||
(typeof errorDetail === 'object' && errorDetail?.error === 'step_up_required') ||
(typeof errorDetail === 'string' && errorDetail.includes('Step-up authentication required'))));
if (isStepUpRequired) {
const actionDescription =
error.stepUpAction ||
(typeof errorDetail === 'object' ? errorDetail?.action : null) ||
errorDetail?.action ||
(typeof errorDetail === 'string' ? errorDetail : null) ||
errorDetail?.message ||
(editingUser ? 'user update' : 'user creation');
logger.debug('Step-up required, opening modal', {
actionDescription,
error: {
requiresStepUp: error.requiresStepUp,
stepUpAction: error.stepUpAction,
status: error.response?.status,
detail: errorDetail
}
});
// Open step-up modal and retry after verification
try {
openStepUp(actionDescription, async () => {
if (pendingSubmitDataRef.current) {
logger.debug('Retrying request after step-up', { data: pendingSubmitDataRef.current });
await executeSubmit(
pendingSubmitDataRef.current.data,
pendingSubmitDataRef.current.isEdit
);
}
});
} catch (err) {
logger.error('Error opening step-up modal', err);
// Fallback: show error message
toast.error('Step-up authentication required. Please verify your identity.');
}
return; // Don't show error toast, step-up modal will handle it
}
}
};

View File

@@ -258,6 +258,12 @@ apiClient.interceptors.response.use(
let errorMessage = 'You do not have permission to access this resource.';
let shouldRetry = false;
// Check for step-up authentication requirement
const isStepUpRequired =
errorData?.error === 'step_up_required' ||
(typeof errorData?.detail === 'object' && errorData?.detail?.error === 'step_up_required') ||
(typeof errorData?.detail === 'string' && errorData?.detail?.includes('Step-up authentication required'));
// Check for MFA requirement error
const isMfaRequired =
errorData?.error === 'mfa_required' ||
@@ -266,7 +272,41 @@ apiClient.interceptors.response.use(
(typeof errorData.detail === 'string' && errorData.detail.includes('Multi-factor authentication is required')) ||
(typeof errorData.detail === 'object' && errorData.detail?.error === 'mfa_required'));
if (isMfaRequired) {
if (isStepUpRequired) {
// Step-up authentication required - dispatch event for UI to handle
const actionDescription = (typeof errorData?.detail === 'object' && errorData?.detail?.action) ||
(typeof errorData?.detail === 'string' ? errorData?.detail : 'this action');
errorMessage = typeof errorData?.detail === 'object' && errorData?.detail?.message
? errorData.detail.message
: `Step-up authentication required for ${actionDescription}. Please verify your identity.`;
// Create retry function for the original request
const retryRequest = async () => {
if (originalRequest && !originalRequest._retry) {
// Mark as retry to prevent infinite loops
originalRequest._retry = true;
// Retry the original request
return apiClient.request(originalRequest);
}
};
// Dispatch custom event for step-up authentication
window.dispatchEvent(new CustomEvent('auth:step-up-required', {
detail: {
action: actionDescription,
message: errorMessage,
originalRequest: retryRequest,
}
}));
return Promise.reject({
...error,
message: errorMessage,
requiresStepUp: true,
stepUpAction: actionDescription,
});
} else if (isMfaRequired) {
// Get user info to determine redirect path
try {
const userInfoStr = localStorage.getItem('userInfo');

View File

@@ -122,12 +122,10 @@ const useFavoritesStore = create<FavoritesState>(
addToFavorites: async (roomId: number) => {
// Don't add favorites if user is not authenticated or not a customer
// Require authentication - only logged-in customers can add favorites
if (!isAuthenticatedCustomer()) {
// Save as guest favorite instead
get().saveGuestFavorite(roomId);
toast.success('Added to favorites');
return;
toast.error('Please login as a customer to add favorites');
throw new Error('Authentication required');
}
try {
@@ -157,27 +155,25 @@ const useFavoritesStore = create<FavoritesState>(
} catch (error: any) {
console.error('Error adding favorite:', error);
if (error.response?.status === 401) {
get().saveGuestFavorite(roomId);
toast.success('Added to favorites');
// Don't fallback to guest favorites - require authentication
if (error.response?.status === 401 || error.response?.status === 403) {
toast.error('Please login as a customer to add favorites');
} else {
const message =
error.response?.data?.message ||
'Unable to add to favorites';
toast.error(message);
}
throw error; // Re-throw to let caller handle
}
},
removeFromFavorites: async (roomId: number) => {
// Don't remove favorites if user is not authenticated or not a customer
// Require authentication - only logged-in customers can remove favorites
if (!isAuthenticatedCustomer()) {
// Remove from guest favorites instead
get().removeGuestFavorite(roomId);
toast.success('Removed from favorites');
return;
toast.error('Please login as a customer to manage favorites');
throw new Error('Authentication required');
}
try {
@@ -209,16 +205,16 @@ const useFavoritesStore = create<FavoritesState>(
} catch (error: any) {
console.error('Error removing favorite:', error);
if (error.response?.status === 401) {
get().removeGuestFavorite(roomId);
toast.success('Removed from favorites');
// Don't fallback to guest favorites - require authentication
if (error.response?.status === 401 || error.response?.status === 403) {
toast.error('Please login as a customer to manage favorites');
} else {
const message =
error.response?.data?.message ||
'Unable to remove from favorites';
toast.error(message);
}
throw error; // Re-throw to let caller handle
}
},