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

@@ -6,7 +6,8 @@
3. AD token 自動刷新5 分鐘內過期時)
4. 重試計數器管理(最多 3 次)
"""
from fastapi import Request, HTTPException, status
from fastapi import Request, status
from fastapi.responses import JSONResponse
from datetime import datetime, timedelta
from app.core.database import SessionLocal
from app.core.config import get_settings
@@ -25,15 +26,19 @@ class AuthMiddleware:
async def __call__(self, request: Request, call_next):
"""Process request through authentication checks"""
# Skip auth for login/logout endpoints
if request.url.path in ["/api/auth/login", "/api/auth/logout", "/docs", "/openapi.json"]:
# Skip auth for non-API routes (frontend), login/logout, and docs
path = request.url.path
if not path.startswith("/api") or path in ["/api/auth/login", "/api/auth/logout", "/api/health"]:
return await call_next(request)
if path in ["/docs", "/openapi.json", "/redoc"]:
return await call_next(request)
# Extract token from Authorization header
authorization = request.headers.get("Authorization")
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "Authentication required"}
)
internal_token = authorization.replace("Bearer ", "")
@@ -44,32 +49,35 @@ class AuthMiddleware:
# Query session
user_session = session_service.get_session_by_token(db, internal_token)
if not user_session:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token"
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "Invalid or expired token"}
)
# Check 3-day inactivity timeout
inactivity_limit = datetime.utcnow() - timedelta(days=settings.SESSION_INACTIVITY_DAYS)
if user_session.last_activity < inactivity_limit:
session_service.delete_session(db, user_session.id)
raise HTTPException(
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired due to inactivity. Please login again.",
content={"detail": "Session expired due to inactivity. Please login again."}
)
# Check if refresh attempts exceeded
if user_session.refresh_attempt_count >= settings.MAX_REFRESH_ATTEMPTS:
session_service.delete_session(db, user_session.id)
raise HTTPException(
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired due to authentication failures. Please login again.",
content={"detail": "Session expired due to authentication failures. Please login again."}
)
# Check if AD token needs refresh (< 5 minutes until expiry)
time_until_expiry = user_session.ad_token_expires_at - datetime.utcnow()
if time_until_expiry < timedelta(minutes=settings.TOKEN_REFRESH_THRESHOLD_MINUTES):
# Auto-refresh AD token
await self._refresh_ad_token(db, user_session)
refresh_error = await self._refresh_ad_token(db, user_session)
if refresh_error:
return refresh_error
# Update last_activity
session_service.update_activity(db, user_session.id)
@@ -87,7 +95,11 @@ class AuthMiddleware:
return await call_next(request)
async def _refresh_ad_token(self, db, user_session):
"""Auto-refresh AD token using stored encrypted password"""
"""Auto-refresh AD token using stored encrypted password
Returns:
JSONResponse on error, None on success
"""
try:
# Decrypt password
password = encryption_service.decrypt_password(user_session.encrypted_password)
@@ -101,6 +113,7 @@ class AuthMiddleware:
)
logger.info(f"AD token refreshed successfully for user: {user_session.username}")
return None # Success
except (ValueError, ConnectionError) as e:
# Refresh failed, increment counter
@@ -117,14 +130,14 @@ class AuthMiddleware:
logger.error(
f"Session terminated for {user_session.username} after {new_count} failed refresh attempts"
)
raise HTTPException(
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session terminated. Your password may have been changed. Please login again.",
content={"detail": "Session terminated. Your password may have been changed. Please login again."}
)
else:
raise HTTPException(
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token refresh failed. Please try again or re-login if issue persists.",
content={"detail": "Token refresh failed. Please try again or re-login if issue persists."}
)

View File

@@ -87,3 +87,31 @@ def get_display_name(db: Session, user_id: str) -> str:
if user:
return user.display_name
return user_id # Fallback to email if user not in database
def search_users(db: Session, query: str, limit: int = 20) -> list[User]:
"""Search users by display_name or user_id (email)
Args:
db: Database session
query: Search query string
limit: Maximum number of results (default 20)
Returns:
List of matching users
"""
from sqlalchemy import or_
search_pattern = f"%{query}%"
return (
db.query(User)
.filter(
or_(
User.display_name.ilike(search_pattern),
User.user_id.ilike(search_pattern)
)
)
.limit(limit)
.all()
)

View File

@@ -0,0 +1,52 @@
"""User management API endpoints
Provides:
- GET /api/users/search - Search users by name or email
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List
from pydantic import BaseModel
from app.core.database import get_db
from app.modules.auth import get_current_user
from app.modules.auth.services.user_service import search_users
router = APIRouter(prefix="/api/users", tags=["Users"])
class UserSearchResult(BaseModel):
"""User search result"""
user_id: str
display_name: str
class Config:
from_attributes = True
@router.get("/search", response_model=List[UserSearchResult])
async def search_users_endpoint(
q: str = Query(..., min_length=1, description="Search query (name or email)"),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Search users by display_name or email
Returns up to 20 matching users. Requires authentication.
Search is case-insensitive and matches partial strings.
"""
if not q or len(q.strip()) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Search query required"
)
users = search_users(db, q.strip(), limit=20)
return [
UserSearchResult(
user_id=user.user_id,
display_name=user.display_name
)
for user in users
]