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:
@@ -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."}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
52
app/modules/auth/users_router.py
Normal file
52
app/modules/auth/users_router.py
Normal 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
|
||||
]
|
||||
Reference in New Issue
Block a user