updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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)}')
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
]
|
||||
|
||||
Binary file not shown.
@@ -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:
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
57
Frontend/src/features/auth/components/StepUpAuthManager.tsx
Normal file
57
Frontend/src/features/auth/components/StepUpAuthManager.tsx
Normal 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;
|
||||
|
||||
335
Frontend/src/features/auth/components/StepUpAuthModal.tsx
Normal file
335
Frontend/src/features/auth/components/StepUpAuthModal.tsx
Normal 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;
|
||||
|
||||
67
Frontend/src/features/auth/contexts/StepUpAuthContext.tsx
Normal file
67
Frontend/src/features/auth/contexts/StepUpAuthContext.tsx
Normal 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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
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<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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user