diff --git a/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc b/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc index 070930ae..a31ea7b2 100644 Binary files a/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc and b/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc differ diff --git a/Backend/src/auth/routes/__pycache__/user_routes.cpython-312.pyc b/Backend/src/auth/routes/__pycache__/user_routes.cpython-312.pyc index f7d1c809..6d21c29a 100644 Binary files a/Backend/src/auth/routes/__pycache__/user_routes.cpython-312.pyc and b/Backend/src/auth/routes/__pycache__/user_routes.cpython-312.pyc differ diff --git a/Backend/src/auth/routes/user_routes.py b/Backend/src/auth/routes/user_routes.py index d48a5922..6140ffba 100644 --- a/Backend/src/auth/routes/user_routes.py +++ b/Backend/src/auth/routes/user_routes.py @@ -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)) \ No newline at end of file + 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)}') \ No newline at end of file diff --git a/Backend/src/auth/schemas/__pycache__/user.cpython-312.pyc b/Backend/src/auth/schemas/__pycache__/user.cpython-312.pyc index 2402639d..f5427187 100644 Binary files a/Backend/src/auth/schemas/__pycache__/user.cpython-312.pyc and b/Backend/src/auth/schemas/__pycache__/user.cpython-312.pyc differ diff --git a/Backend/src/auth/schemas/user.py b/Backend/src/auth/schemas/user.py index b0b55f2e..1a5bd476 100644 --- a/Backend/src/auth/schemas/user.py +++ b/Backend/src/auth/schemas/user.py @@ -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 diff --git a/Backend/src/hotel_services/routes/__pycache__/guest_request_routes.cpython-312.pyc b/Backend/src/hotel_services/routes/__pycache__/guest_request_routes.cpython-312.pyc index ec0e1d12..fdef5e38 100644 Binary files a/Backend/src/hotel_services/routes/__pycache__/guest_request_routes.cpython-312.pyc and b/Backend/src/hotel_services/routes/__pycache__/guest_request_routes.cpython-312.pyc differ diff --git a/Backend/src/hotel_services/routes/guest_request_routes.py b/Backend/src/hotel_services/routes/guest_request_routes.py index 7fe60ed0..91d75cd7 100644 --- a/Backend/src/hotel_services/routes/guest_request_routes.py +++ b/Backend/src/hotel_services/routes/guest_request_routes.py @@ -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', diff --git a/Backend/src/integrations/routes/__pycache__/api_key_routes.cpython-312.pyc b/Backend/src/integrations/routes/__pycache__/api_key_routes.cpython-312.pyc index 09a389d2..b6c9abec 100644 Binary files a/Backend/src/integrations/routes/__pycache__/api_key_routes.cpython-312.pyc and b/Backend/src/integrations/routes/__pycache__/api_key_routes.cpython-312.pyc differ diff --git a/Backend/src/integrations/routes/__pycache__/webhook_routes.cpython-312.pyc b/Backend/src/integrations/routes/__pycache__/webhook_routes.cpython-312.pyc index 31f5a438..b4c62105 100644 Binary files a/Backend/src/integrations/routes/__pycache__/webhook_routes.cpython-312.pyc and b/Backend/src/integrations/routes/__pycache__/webhook_routes.cpython-312.pyc differ diff --git a/Backend/src/integrations/routes/api_key_routes.py b/Backend/src/integrations/routes/api_key_routes.py index 4905803a..37343a5a 100644 --- a/Backend/src/integrations/routes/api_key_routes.py +++ b/Backend/src/integrations/routes/api_key_routes.py @@ -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)) diff --git a/Backend/src/integrations/routes/webhook_routes.py b/Backend/src/integrations/routes/webhook_routes.py index f87830fa..25b754b7 100644 --- a/Backend/src/integrations/routes/webhook_routes.py +++ b/Backend/src/integrations/routes/webhook_routes.py @@ -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)) diff --git a/Backend/src/models/__init__.py b/Backend/src/models/__init__.py index 06873e08..5a805f6a 100644 --- a/Backend/src/models/__init__.py +++ b/Backend/src/models/__init__.py @@ -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', ] diff --git a/Backend/src/models/__pycache__/__init__.cpython-312.pyc b/Backend/src/models/__pycache__/__init__.cpython-312.pyc index a15242bc..ef8cd973 100644 Binary files a/Backend/src/models/__pycache__/__init__.cpython-312.pyc and b/Backend/src/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/Backend/src/notifications/routes/__pycache__/team_chat_routes.cpython-312.pyc b/Backend/src/notifications/routes/__pycache__/team_chat_routes.cpython-312.pyc index bcc6f5c6..09bc09ae 100644 Binary files a/Backend/src/notifications/routes/__pycache__/team_chat_routes.cpython-312.pyc and b/Backend/src/notifications/routes/__pycache__/team_chat_routes.cpython-312.pyc differ diff --git a/Backend/src/notifications/routes/team_chat_routes.py b/Backend/src/notifications/routes/team_chat_routes.py index 97ef9763..2655c9cb 100644 --- a/Backend/src/notifications/routes/team_chat_routes.py +++ b/Backend/src/notifications/routes/team_chat_routes.py @@ -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 ] diff --git a/Backend/src/payments/routes/__pycache__/accountant_security_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/accountant_security_routes.cpython-312.pyc index e27141bd..d6e4afe8 100644 Binary files a/Backend/src/payments/routes/__pycache__/accountant_security_routes.cpython-312.pyc and b/Backend/src/payments/routes/__pycache__/accountant_security_routes.cpython-312.pyc differ diff --git a/Backend/src/payments/routes/accountant_security_routes.py b/Backend/src/payments/routes/accountant_security_routes.py index 8726d7b7..299d03bc 100644 --- a/Backend/src/payments/routes/accountant_security_routes.py +++ b/Backend/src/payments/routes/accountant_security_routes.py @@ -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: diff --git a/Backend/src/payments/services/__pycache__/accountant_security_service.cpython-312.pyc b/Backend/src/payments/services/__pycache__/accountant_security_service.cpython-312.pyc index 4fc18af6..45554a4c 100644 Binary files a/Backend/src/payments/services/__pycache__/accountant_security_service.cpython-312.pyc and b/Backend/src/payments/services/__pycache__/accountant_security_service.cpython-312.pyc differ diff --git a/Backend/src/payments/services/accountant_security_service.py b/Backend/src/payments/services/accountant_security_service.py index 1f50764b..aa9dab89 100644 --- a/Backend/src/payments/services/accountant_security_service.py +++ b/Backend/src/payments/services/accountant_security_service.py @@ -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 diff --git a/Backend/src/reviews/routes/__pycache__/favorite_routes.cpython-312.pyc b/Backend/src/reviews/routes/__pycache__/favorite_routes.cpython-312.pyc index e9151ae0..6443b471 100644 Binary files a/Backend/src/reviews/routes/__pycache__/favorite_routes.cpython-312.pyc and b/Backend/src/reviews/routes/__pycache__/favorite_routes.cpython-312.pyc differ diff --git a/Backend/src/reviews/routes/favorite_routes.py b/Backend/src/reviews/routes/favorite_routes.py index 3d615689..f6f41bc5 100644 --- a/Backend/src/reviews/routes/favorite_routes.py +++ b/Backend/src/reviews/routes/favorite_routes.py @@ -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() diff --git a/Backend/venv/lib/python3.12/site-packages/qrcode/image/__pycache__/pil.cpython-312.pyc b/Backend/venv/lib/python3.12/site-packages/qrcode/image/__pycache__/pil.cpython-312.pyc new file mode 100644 index 00000000..15cdf865 Binary files /dev/null and b/Backend/venv/lib/python3.12/site-packages/qrcode/image/__pycache__/pil.cpython-312.pyc differ diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 8ea7e8a8..d4b11d73 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -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() { - + + } /> + + + + + + } + /> + - + + diff --git a/Frontend/src/features/auth/components/StepUpAuthManager.tsx b/Frontend/src/features/auth/components/StepUpAuthManager.tsx new file mode 100644 index 00000000..1e8e3ffd --- /dev/null +++ b/Frontend/src/features/auth/components/StepUpAuthManager.tsx @@ -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) => 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 ( + + ); +}; + +export default StepUpAuthManager; + diff --git a/Frontend/src/features/auth/components/StepUpAuthModal.tsx b/Frontend/src/features/auth/components/StepUpAuthModal.tsx new file mode 100644 index 00000000..f80de1cd --- /dev/null +++ b/Frontend/src/features/auth/components/StepUpAuthModal.tsx @@ -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; +type PasswordFormData = yup.InferType; + +interface StepUpAuthModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + actionDescription?: string; +} + +const StepUpAuthModal: React.FC = ({ + 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(null); + + const { + register: registerMFA, + handleSubmit: handleSubmitMFA, + formState: { errors: mfaErrors }, + reset: resetMFA, + } = useForm({ + resolver: yupResolver(mfaTokenSchema), + defaultValues: { + mfaToken: '', + }, + }); + + const { + register: registerPassword, + handleSubmit: handleSubmitPassword, + formState: { errors: passwordErrors }, + reset: resetPassword, + } = useForm({ + 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 ( +
{ + if (e.target === e.currentTarget && !isVerifying) { + onClose(); + } + }} + > + {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Close button */} + {!isVerifying && ( + + )} + +
+ {/* Header */} +
+
+ +
+

Verify Your Identity

+

+ Step-up authentication is required for {actionDescription} +

+
+ + {/* Error message */} + {error && ( +
{error}
+ )} + + {/* Verification method selector */} +
+ + +
+ + {/* MFA Form */} + {verificationMethod === 'mfa' && ( +
+
+ + + {mfaErrors.mfaToken && ( +

{mfaErrors.mfaToken.message}

+ )} +

Enter the 6-digit code from your authenticator app

+
+ + +
+ )} + + {/* Password Form */} + {verificationMethod === 'password' && ( +
+
+ + + {passwordErrors.password && ( +

{passwordErrors.password.message}

+ )} +

Enter your password to verify your identity

+
+ + +
+ )} + + {/* Info */} +
+

+ Note: Step-up authentication is valid for 15 minutes. You won't need to verify again for + similar actions during this time. +

+
+
+
+
+ ); +}; + +export default StepUpAuthModal; + diff --git a/Frontend/src/features/auth/contexts/StepUpAuthContext.tsx b/Frontend/src/features/auth/contexts/StepUpAuthContext.tsx new file mode 100644 index 00000000..2ef3a008 --- /dev/null +++ b/Frontend/src/features/auth/contexts/StepUpAuthContext.tsx @@ -0,0 +1,67 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; + +interface StepUpAuthContextType { + isOpen: boolean; + actionDescription: string; + pendingRequest: (() => Promise) | null; + openStepUp: (actionDescription: string, pendingRequest: () => Promise) => void; + closeStepUp: () => void; + onStepUpSuccess: () => void; +} + +const StepUpAuthContext = createContext(undefined); + +export const StepUpAuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isOpen, setIsOpen] = useState(false); + const [actionDescription, setActionDescription] = useState(''); + const [pendingRequest, setPendingRequest] = useState<(() => Promise) | null>(null); + + const openStepUp = useCallback((action: string, request: () => Promise) => { + 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 ( + + {children} + + ); +}; + +export const useStepUpAuth = () => { + const context = useContext(StepUpAuthContext); + if (context === undefined) { + throw new Error('useStepUpAuth must be used within a StepUpAuthProvider'); + } + return context; +}; + diff --git a/Frontend/src/features/rooms/components/FavoriteButton.tsx b/Frontend/src/features/rooms/components/FavoriteButton.tsx index a96d5fe6..dc88582c 100644 --- a/Frontend/src/features/rooms/components/FavoriteButton.tsx +++ b/Frontend/src/features/rooms/components/FavoriteButton.tsx @@ -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 = ({ showTooltip = true, className = '', }) => { - const { userInfo } = useAuthStore(); + const { userInfo, isAuthenticated } = useAuthStore(); + const { openModal } = useAuthModal(); const { isFavorited, addToFavorites, @@ -26,6 +29,7 @@ const FavoriteButton: React.FC = ({ 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 = ({ 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) { diff --git a/Frontend/src/features/team-chat/components/TeamChatPage.tsx b/Frontend/src/features/team-chat/components/TeamChatPage.tsx index e3c1295b..87cdfcb2 100644 --- a/Frontend/src/features/team-chat/components/TeamChatPage.tsx +++ b/Frontend/src/features/team-chat/components/TeamChatPage.tsx @@ -51,6 +51,12 @@ const TeamChatPage: React.FC = ({ role }) => { const [editContent, setEditContent] = useState(''); const messagesEndRef = useRef(null); const [ws, setWs] = useState(null); + const selectedChannelRef = useRef(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 = ({ 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); - - 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)); + try { + const data = JSON.parse(event.data); + + // 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 = ({ 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 = ({ 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]); diff --git a/Frontend/src/pages/admin/UserManagementPage.tsx b/Frontend/src/pages/admin/UserManagementPage.tsx index 35d8eb11..799a31dc 100644 --- a/Frontend/src/pages/admin/UserManagementPage.tsx +++ b/Frontend/src/pages/admin/UserManagementPage.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showModal, setShowModal] = useState(false); const [editingUser, setEditingUser] = useState(null); const [deletingUserId, setDeletingUserId] = useState(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, - }; - - if (editingUser) { - if (formData.password && formData.password.trim() !== '') { - submitData.password = formData.password; - } - logger.debug('Updating user', { userId: editingUser.id, updateData: submitData }); - } else { + 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('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 + } } }; diff --git a/Frontend/src/shared/services/apiClient.ts b/Frontend/src/shared/services/apiClient.ts index 7371bca2..50711ecc 100644 --- a/Frontend/src/shared/services/apiClient.ts +++ b/Frontend/src/shared/services/apiClient.ts @@ -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'); diff --git a/Frontend/src/store/useFavoritesStore.ts b/Frontend/src/store/useFavoritesStore.ts index 9cdb9b57..b774a7fe 100644 --- a/Frontend/src/store/useFavoritesStore.ts +++ b/Frontend/src/store/useFavoritesStore.ts @@ -122,12 +122,10 @@ const useFavoritesStore = create( 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( } 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( } 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 } },