feat: Add mobile responsive layout, open room access, and admin room management

Mobile Responsive Layout:
- Add useMediaQuery, useIsMobile, useIsTablet, useIsDesktop hooks for device detection
- Create MobileHeader component with hamburger menu and action drawer
- Create BottomToolbar for mobile navigation (Files, Members)
- Create SlidePanel component for full-screen mobile sidebars
- Update RoomDetail.tsx with mobile/desktop conditional rendering
- Update RoomList.tsx with single-column grid and touch-friendly buttons
- Add CSS custom properties for safe areas and touch targets (min 44px)
- Add mobile viewport meta tags for notched devices

Open Room Access:
- All authenticated users can view all rooms (not just their own)
- Users can join active rooms they're not members of
- Add is_member field to room responses
- Update room list API to return all rooms by default

Admin Room Management:
- Add permanent delete functionality for system admins
- Add delete confirmation dialog with room title verification
- Broadcast room deletion via WebSocket to connected users
- Add users search API for adding members

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-05 09:12:10 +08:00
parent 1e44a63a8e
commit 1d5d4d447d
48 changed files with 3505 additions and 401 deletions

View File

@@ -7,7 +7,7 @@ from sqlalchemy import and_
from typing import List, Optional
from datetime import datetime
from app.modules.chat_room.models import RoomMember, IncidentRoom, MemberRole
from app.modules.chat_room.models import RoomMember, IncidentRoom, MemberRole, RoomStatus
class MembershipService:
@@ -16,6 +16,73 @@ class MembershipService:
# System admin email (hardcoded as per requirement)
SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw"
def self_join_room(
self,
db: Session,
room_id: str,
user_id: str
) -> tuple[Optional[RoomMember], str, Optional[RoomMember]]:
"""Self-join a room as a viewer
Allows any authenticated user to join a room without invitation.
User joins as VIEWER role.
Args:
db: Database session
room_id: Room ID
user_id: User joining
Returns:
Tuple of (member, error_code, existing_member)
- On success: (member, "", None)
- If already member: (None, "already_member", existing_member)
- If room archived: (None, "room_archived", None)
- If room not found: (None, "room_not_found", None)
"""
# Check if room exists
room = db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if not room:
return None, "room_not_found", None
# Check if room is archived
if room.status == RoomStatus.ARCHIVED:
return None, "room_archived", None
# Check if already a member
existing = db.query(RoomMember).filter(
and_(
RoomMember.room_id == room_id,
RoomMember.user_id == user_id,
RoomMember.removed_at.is_(None)
)
).first()
if existing:
return None, "already_member", existing
# Create membership as viewer
member = RoomMember(
room_id=room_id,
user_id=user_id,
role=MemberRole.VIEWER,
added_by=user_id, # Self-added
added_at=datetime.utcnow()
)
db.add(member)
# Update member count
self._update_member_count(db, room_id)
# Update room activity
room.last_activity_at = datetime.utcnow()
db.commit()
db.refresh(member)
return member, "", None
def add_member(
self,
db: Session,
@@ -101,6 +168,120 @@ class MembershipService:
db.commit()
return True
def can_change_member_role(
self,
db: Session,
room_id: str,
changer_id: str,
target_id: str,
current_role: MemberRole,
new_role: MemberRole
) -> tuple[bool, str]:
"""Check if a user can change another member's role
Permission rules:
- OWNER can change any role
- EDITOR can upgrade VIEWER → EDITOR only
- EDITOR cannot downgrade, remove, or set OWNER role
- VIEWER cannot change roles
Args:
db: Database session
room_id: Room ID
changer_id: User attempting the change
target_id: Target user
current_role: Target's current role
new_role: Requested new role
Returns:
Tuple of (allowed, error_message)
"""
# System admin can do anything
if self.is_system_admin(changer_id):
return True, ""
changer_role = self.get_user_role_in_room(db, room_id, changer_id)
if not changer_role:
return False, "Not a member of this room"
# Owner can change any role
if changer_role == MemberRole.OWNER:
return True, ""
# Editor permissions
if changer_role == MemberRole.EDITOR:
# Cannot set owner role
if new_role == MemberRole.OWNER:
return False, "Only owner can transfer ownership"
# Can only upgrade viewer to editor
if current_role == MemberRole.VIEWER and new_role == MemberRole.EDITOR:
return True, ""
# Cannot downgrade
if current_role == MemberRole.EDITOR and new_role == MemberRole.VIEWER:
return False, "Editors can only upgrade members"
return False, "Editors can only upgrade viewers to editor"
# Viewer cannot change roles
return False, "Insufficient permissions"
def can_remove_member(
self,
db: Session,
room_id: str,
remover_id: str,
target_id: str
) -> tuple[bool, str]:
"""Check if a user can remove another member
Permission rules:
- OWNER can remove any member
- EDITOR cannot remove members
- VIEWER cannot remove members
- Users can remove themselves (leave room)
Args:
db: Database session
room_id: Room ID
remover_id: User attempting the removal
target_id: Target user
Returns:
Tuple of (allowed, error_message)
"""
# System admin can do anything
if self.is_system_admin(remover_id):
return True, ""
remover_role = self.get_user_role_in_room(db, room_id, remover_id)
if not remover_role:
return False, "Not a member of this room"
# User can leave the room (remove themselves)
if remover_id == target_id:
# But owner cannot leave if they're the only owner
if remover_role == MemberRole.OWNER:
members = self.get_room_members(db, room_id)
owner_count = sum(1 for m in members if m.role == MemberRole.OWNER)
if owner_count == 1:
return False, "Cannot leave: you are the only owner"
return True, ""
# Owner can remove any member
if remover_role == MemberRole.OWNER:
return True, ""
# Editor cannot remove members
if remover_role == MemberRole.EDITOR:
return False, "Only owner can remove members"
# Viewer cannot remove members
return False, "Insufficient permissions"
def update_member_role(
self,
db: Session,

View File

@@ -4,13 +4,16 @@ Handles business logic for room CRUD operations
"""
from sqlalchemy.orm import Session
from sqlalchemy import or_, and_, func
from typing import List, Optional, Dict
from typing import List, Optional, Dict, Tuple
from datetime import datetime
import uuid
import logging
from app.modules.chat_room.models import IncidentRoom, RoomMember, RoomStatus, MemberRole
from app.modules.chat_room.schemas import CreateRoomRequest, UpdateRoomRequest, RoomFilterParams
logger = logging.getLogger(__name__)
class RoomService:
"""Service for room management operations"""
@@ -111,6 +114,10 @@ class RoomService:
) -> List[IncidentRoom]:
"""List rooms accessible to user with filters
All authenticated users can see all rooms by default.
Use my_rooms=true to filter to only rooms where user is a member.
Non-admin users cannot see ARCHIVED rooms.
Args:
db: Database session
user_id: User ID
@@ -118,13 +125,16 @@ class RoomService:
is_admin: Whether user is system admin
Returns:
List of accessible rooms
List of rooms and total count
"""
# Non-admin requesting archived rooms explicitly - return empty
if not is_admin and filters.status == RoomStatus.ARCHIVED:
return [], 0
query = db.query(IncidentRoom)
# Access control: admin sees all, others see only their rooms
if not is_admin or not filters.all:
# Join with room_members to filter by membership
# Filter to user's rooms only if my_rooms=true
if filters.my_rooms:
query = query.join(RoomMember).filter(
and_(
RoomMember.user_id == user_id,
@@ -132,6 +142,10 @@ class RoomService:
)
)
# Hide archived rooms from non-admin users
if not is_admin:
query = query.filter(IncidentRoom.status != RoomStatus.ARCHIVED)
# Apply filters
if filters.status:
query = query.filter(IncidentRoom.status == filters.status)
@@ -381,6 +395,86 @@ class RoomService:
room.last_activity_at = datetime.utcnow()
db.commit()
def permanent_delete_room(
self,
db: Session,
room_id: str
) -> Tuple[bool, Optional[str]]:
"""Permanently delete a room and all associated data (admin only)
This is an irreversible operation that:
1. Deletes all files from MinIO storage
2. Deletes all report documents from MinIO storage
3. Cascades delete to all related database records
Args:
db: Database session
room_id: Room ID to permanently delete
Returns:
Tuple of (success, error_message)
"""
# Late imports to avoid circular dependency
from app.modules.file_storage.models import RoomFile
from app.modules.report_generation.models import GeneratedReport
from app.modules.file_storage.services import minio_service
from app.core.config import get_settings
settings = get_settings()
# Check room exists
room = db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if not room:
return False, "Room not found"
try:
# Step 1: Delete room files from MinIO
room_files = db.query(RoomFile).filter(
RoomFile.room_id == room_id
).all()
for rf in room_files:
if rf.minio_object_path:
success = minio_service.delete_file(
rf.minio_bucket or settings.MINIO_BUCKET,
rf.minio_object_path
)
if not success:
logger.warning(
f"Failed to delete MinIO file: {rf.minio_object_path}"
)
# Step 2: Delete generated report documents from MinIO
reports = db.query(GeneratedReport).filter(
GeneratedReport.room_id == room_id
).all()
for report in reports:
if report.docx_storage_path:
success = minio_service.delete_file(
settings.MINIO_BUCKET,
report.docx_storage_path
)
if not success:
logger.warning(
f"Failed to delete report file: {report.docx_storage_path}"
)
# Step 3: Delete room from database (CASCADE handles related tables)
db.delete(room)
db.commit()
logger.info(f"Permanently deleted room {room_id} and all associated data")
return True, None
except Exception as e:
db.rollback()
logger.error(f"Failed to permanently delete room {room_id}: {e}")
return False, str(e)
# Create singleton instance
room_service = RoomService()