feat: Initial commit - Task Reporter incident response system

Complete implementation of the production line incident response system (生產線異常即時反應系統) including:

Backend (FastAPI):
- User authentication with AD integration and session management
- Chat room management (create, list, update, members, roles)
- Real-time messaging via WebSocket (typing indicators, reactions)
- File storage with MinIO (upload, download, image preview)

Frontend (React + Vite):
- Authentication flow with token management
- Room list with filtering, search, and pagination
- Real-time chat interface with WebSocket
- File upload with drag-and-drop and image preview
- Member management and room settings
- Breadcrumb navigation
- 53 unit tests (Vitest)

Specifications:
- authentication: AD auth, sessions, JWT tokens
- chat-room: rooms, members, templates
- realtime-messaging: WebSocket, messages, reactions
- file-storage: MinIO integration, file management
- frontend-core: React SPA structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-01 17:42:52 +08:00
commit c8966477b9
135 changed files with 23269 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Authentication services"""

View File

@@ -0,0 +1,98 @@
"""AD API client service for authentication
與 Panjit AD API 整合,負責:
- 驗證使用者憑證
- 取得 AD token 和使用者名稱
- 處理 API 連線錯誤
"""
from datetime import datetime, timedelta
import httpx
from typing import Dict
from app.core.config import get_settings
settings = get_settings()
class ADAuthService:
"""Active Directory authentication service"""
def __init__(self):
self.ad_api_url = settings.AD_API_URL
self._client = httpx.AsyncClient(timeout=10.0)
async def authenticate(self, username: str, password: str) -> Dict[str, any]:
"""Authenticate user with AD API
Args:
username: User email (e.g., ymirliu@panjit.com.tw)
password: User password
Returns:
Dict containing:
- token: AD authentication token
- username: Display name from AD
- expires_at: Estimated token expiry datetime
Raises:
httpx.HTTPStatusError: If authentication fails (401, 403)
httpx.RequestError: If AD API is unreachable
"""
payload = {"username": username, "password": password}
try:
response = await self._client.post(
self.ad_api_url, json=payload, headers={"Content-Type": "application/json"}
)
# Raise exception for 4xx/5xx status codes
response.raise_for_status()
data = response.json()
# Extract token and username from response
# Response structure: {"success": true, "data": {"access_token": "...", "userInfo": {"name": "...", "email": "..."}}}
if not data.get("success"):
raise ValueError("Authentication failed")
token_data = data.get("data", {})
ad_token = token_data.get("access_token")
user_info = token_data.get("userInfo", {})
display_name = user_info.get("name") or username
if not ad_token:
raise ValueError("No token received from AD API")
# Parse expiry time from response (expiresAt field)
expires_at_str = token_data.get("expiresAt")
if expires_at_str:
# Parse ISO format: "2025-11-16T14:38:37.912Z"
try:
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
except:
expires_at = datetime.utcnow() + timedelta(hours=1)
else:
# Fallback: assume 1 hour if not provided
expires_at = datetime.utcnow() + timedelta(hours=1)
return {"token": ad_token, "username": display_name, "expires_at": expires_at}
except httpx.HTTPStatusError as e:
# Authentication failed (401) or other HTTP errors
if e.response.status_code == 401:
raise ValueError("Invalid credentials") from e
elif e.response.status_code >= 500:
raise ConnectionError("Authentication service error") from e
else:
raise
except httpx.RequestError as e:
# Network error, timeout, etc.
raise ConnectionError("Authentication service unavailable") from e
async def close(self):
"""Close HTTP client"""
await self._client.aclose()
# Singleton instance
ad_auth_service = ADAuthService()

View File

@@ -0,0 +1,47 @@
"""Password encryption service using Fernet (AES-256)
安全性說明:
- 使用 Fernet 對稱加密(基於 AES-256
- 加密金鑰從環境變數 FERNET_KEY 讀取
- 密碼加密後儲存於資料庫,用於自動刷新 AD token
"""
from cryptography.fernet import Fernet
from app.core.config import get_settings
settings = get_settings()
class EncryptionService:
"""Password encryption/decryption service"""
def __init__(self):
"""Initialize with Fernet key from settings"""
self._fernet = Fernet(settings.FERNET_KEY.encode())
def encrypt_password(self, plaintext: str) -> str:
"""Encrypt password for storage
Args:
plaintext: Plain text password
Returns:
Encrypted password as base64 string
"""
encrypted_bytes = self._fernet.encrypt(plaintext.encode())
return encrypted_bytes.decode()
def decrypt_password(self, ciphertext: str) -> str:
"""Decrypt stored password
Args:
ciphertext: Encrypted password (base64 string)
Returns:
Decrypted plain text password
"""
decrypted_bytes = self._fernet.decrypt(ciphertext.encode())
return decrypted_bytes.decode()
# Singleton instance
encryption_service = EncryptionService()

View File

@@ -0,0 +1,144 @@
"""Session management service
處理 user_sessions 資料庫操作:
- 建立/查詢/刪除 session
- 更新活動時間戳
- 管理 refresh 重試計數器
"""
import uuid
from datetime import datetime
from sqlalchemy.orm import Session
from app.modules.auth.models import UserSession
class SessionService:
"""Session management service"""
def create_session(
self,
db: Session,
username: str,
display_name: str,
ad_token: str,
encrypted_password: str,
ad_token_expires_at: datetime,
) -> UserSession:
"""Create new user session
Args:
db: Database session
username: User email from AD
display_name: Display name from AD
ad_token: AD API token
encrypted_password: Encrypted password for auto-refresh
ad_token_expires_at: AD token expiry datetime
Returns:
Created UserSession object
"""
# Generate unique internal token
internal_token = str(uuid.uuid4())
session = UserSession(
username=username,
display_name=display_name,
internal_token=internal_token,
ad_token=ad_token,
encrypted_password=encrypted_password,
ad_token_expires_at=ad_token_expires_at,
refresh_attempt_count=0,
last_activity=datetime.utcnow(),
)
db.add(session)
db.commit()
db.refresh(session)
return session
def get_session_by_token(self, db: Session, internal_token: str) -> UserSession | None:
"""Get session by internal token
Args:
db: Database session
internal_token: Internal session token (UUID)
Returns:
UserSession if found, None otherwise
"""
return db.query(UserSession).filter(UserSession.internal_token == internal_token).first()
def update_activity(self, db: Session, session_id: int) -> None:
"""Update last_activity timestamp
Args:
db: Database session
session_id: Session ID
"""
db.query(UserSession).filter(UserSession.id == session_id).update(
{"last_activity": datetime.utcnow()}
)
db.commit()
def update_ad_token(
self, db: Session, session_id: int, new_ad_token: str, new_expires_at: datetime
) -> None:
"""Update AD token after successful refresh
Args:
db: Database session
session_id: Session ID
new_ad_token: New AD token
new_expires_at: New expiry datetime
"""
db.query(UserSession).filter(UserSession.id == session_id).update(
{
"ad_token": new_ad_token,
"ad_token_expires_at": new_expires_at,
"refresh_attempt_count": 0, # Reset counter on success
}
)
db.commit()
def increment_refresh_attempts(self, db: Session, session_id: int) -> int:
"""Increment refresh attempt counter
Args:
db: Database session
session_id: Session ID
Returns:
New refresh_attempt_count value
"""
session = db.query(UserSession).filter(UserSession.id == session_id).first()
if session:
session.refresh_attempt_count += 1
db.commit()
return session.refresh_attempt_count
return 0
def reset_refresh_attempts(self, db: Session, session_id: int) -> None:
"""Reset refresh attempt counter
Args:
db: Database session
session_id: Session ID
"""
db.query(UserSession).filter(UserSession.id == session_id).update(
{"refresh_attempt_count": 0}
)
db.commit()
def delete_session(self, db: Session, session_id: int) -> None:
"""Delete session
Args:
db: Database session
session_id: Session ID
"""
db.query(UserSession).filter(UserSession.id == session_id).delete()
db.commit()
# Singleton instance
session_service = SessionService()