added MSAL microsoft authentication
This commit is contained in:
parent
0910ade371
commit
665b49c3f1
31 changed files with 4846 additions and 53 deletions
106
.env.local
Normal file
106
.env.local
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# =============================================================================
|
||||
# Local Development Environment Variables for Accessible Video Platform
|
||||
# =============================================================================
|
||||
# IMPORTANT: This file is for local Docker-based development only
|
||||
# Usage: ./scripts/run-local.sh (backend) + npm run dev (frontend)
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# App Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
APP_ENV=dev
|
||||
API_BASE_URL=http://localhost:8000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Authentication & Security
|
||||
# -----------------------------------------------------------------------------
|
||||
# Using same JWT secret as production (shared between environments)
|
||||
JWT_SECRET=CHANGE_ME_TO_SECURE_RANDOM_64_CHAR_STRING
|
||||
JWT_ALG=HS256
|
||||
JWT_ACCESS_TTL_MIN=240
|
||||
JWT_REFRESH_TTL_DAYS=7
|
||||
|
||||
# Local development cookie settings (HTTP, not HTTPS)
|
||||
COOKIE_DOMAIN=localhost
|
||||
COOKIE_SECURE=false
|
||||
COOKIE_SAMESITE=Lax
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# MongoDB Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# MongoDB runs without authentication in the internal Docker network
|
||||
MONGODB_DB=accessible_video
|
||||
|
||||
# Note: MongoDB connection string is auto-constructed in docker-compose.yml
|
||||
# Format: mongodb://mongodb:27017/${MONGODB_DB}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Redis Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Redis runs without authentication in the internal Docker network
|
||||
# No configuration needed - connection strings in docker-compose.yml
|
||||
# REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Google Cloud Platform (GCP)
|
||||
# -----------------------------------------------------------------------------
|
||||
GCP_PROJECT_ID=optical-414516
|
||||
GCS_BUCKET=accessible-video
|
||||
|
||||
# GCP credentials file will be mounted as a volume
|
||||
# Location inside container: /secrets/gcp-credentials.json
|
||||
# Local source: ./secrets/gcp-credentials.json
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Services
|
||||
# -----------------------------------------------------------------------------
|
||||
# Using same API keys as production
|
||||
GEMINI_API_KEY=AIzaSyAuuVGcvqfoP7pqX-YwieGszPsNSeAft-0
|
||||
|
||||
# Google Cloud Translate (Optional - for translation features)
|
||||
TRANSLATE_API_KEY=
|
||||
|
||||
# ElevenLabs TTS (Optional - for text-to-speech)
|
||||
ELEVENLABS_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Email Configuration (SendGrid)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Optional: Leave empty to disable email sending in local dev
|
||||
SENDGRID_API_KEY=
|
||||
|
||||
# Email sender address (local development)
|
||||
EMAIL_FROM=noreply@localhost
|
||||
|
||||
# Client-facing URL (used in emails) - points to local frontend
|
||||
CLIENT_BASE_URL=http://localhost:6001/video-accessibility
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Microsoft Authentication (Azure AD)
|
||||
# -----------------------------------------------------------------------------
|
||||
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
|
||||
AZURE_AUTHORITY=https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385
|
||||
AZURE_REDIRECT_URI=http://localhost:6001/video-accessibility/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CORS Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Comma-separated list of allowed origins for local development
|
||||
CORS_ORIGINS=http://localhost:6001,http://localhost:5173,http://localhost:3000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Observability & Monitoring (Optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Disabled for local development
|
||||
SENTRY_DSN=
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
|
||||
# =============================================================================
|
||||
# LOCAL DEVELOPMENT NOTES
|
||||
# =============================================================================
|
||||
# - Backend services run in Docker: API (port 8003), Worker, MongoDB, Redis
|
||||
# - Frontend runs via npm: http://localhost:6001/video-accessibility
|
||||
# - MongoDB and Redis data persists in Docker volumes
|
||||
# - Same GCP credentials and API keys as production
|
||||
# - Cookies work on localhost (not secure, for dev only)
|
||||
# =============================================================================
|
||||
|
|
@ -74,6 +74,19 @@ EMAIL_FROM=noreply@ai-sandbox.oliver.solutions
|
|||
# Client-facing URL (used in emails)
|
||||
CLIENT_BASE_URL=https://ai-sandbox.oliver.solutions/video-accessibility
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Microsoft Authentication (Azure AD)
|
||||
# -----------------------------------------------------------------------------
|
||||
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
|
||||
AZURE_AUTHORITY=https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385
|
||||
AZURE_REDIRECT_URI=https://ai-sandbox.oliver.solutions/video-accessibility/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CORS Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Comma-separated list of allowed origins
|
||||
CORS_ORIGINS=https://ai-sandbox.oliver.solutions
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Observability & Monitoring (Optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
|
|||
116
README.md
116
README.md
|
|
@ -74,49 +74,105 @@ Storage Pro + Validation Review Translate Speech Approval D
|
|||
|
||||
### Prerequisites
|
||||
- **Python 3.11+** (backend development)
|
||||
- **Node.js 18+** (frontend development)
|
||||
- **Poetry** (Python dependency management)
|
||||
- **Docker & Docker Compose** (local development)
|
||||
- **Google Cloud Project** with APIs enabled
|
||||
- **MongoDB Atlas** (recommended) or local MongoDB
|
||||
- **Redis** (included in docker-compose)
|
||||
- **Node.js 18+** (frontend development)
|
||||
- **Docker & Docker Compose** (required for local development)
|
||||
- **Google Cloud Project** with APIs enabled (for video processing)
|
||||
|
||||
### Quick Start with Docker 🐳
|
||||
### 🐳 Local Development with Docker (Recommended)
|
||||
|
||||
This is the recommended approach for local development. Backend services run in Docker containers while the frontend runs via Vite dev server for fast hot-reload.
|
||||
|
||||
#### Initial Setup
|
||||
```bash
|
||||
# 1. Clone and setup
|
||||
# 1. Clone the repository
|
||||
git clone <repository>
|
||||
cd video_accessibility
|
||||
|
||||
# 2. Configure environment (copy and edit sample files)
|
||||
cp backend/.env.example backend/.env
|
||||
cp frontend/.env.example frontend/.env
|
||||
# 2. Copy and configure environment files
|
||||
cp .env.prod.example .env.local
|
||||
# Edit .env.local with your API keys and settings
|
||||
|
||||
# 3. Start all services
|
||||
docker-compose up -d
|
||||
# 3. Set up frontend environment
|
||||
cp frontend/.env.example frontend/.env.local
|
||||
# The defaults should work for local development
|
||||
|
||||
# 4. Access the application
|
||||
# Frontend: http://localhost:5173
|
||||
# Backend API: http://localhost:8000
|
||||
# API Docs: http://localhost:8000/docs
|
||||
# 4. Ensure GCP credentials are in place
|
||||
# Copy your GCP service account JSON to: ./secrets/gcp-credentials.json
|
||||
```
|
||||
|
||||
### Local Development Setup
|
||||
#### Starting the Development Environment
|
||||
|
||||
**Step 1: Start Backend Services (Docker)**
|
||||
```bash
|
||||
# Backend
|
||||
# Start API, Worker, MongoDB, and Redis in Docker
|
||||
./scripts/run-local.sh
|
||||
|
||||
# Services will be available at:
|
||||
# - API: http://localhost:8003
|
||||
# - API Docs: http://localhost:8003/docs
|
||||
# - MongoDB: mongodb://localhost:27017
|
||||
# - Redis: redis://localhost:6379
|
||||
```
|
||||
|
||||
**Step 2: Start Frontend (Vite Dev Server)**
|
||||
```bash
|
||||
# In a separate terminal
|
||||
cd frontend
|
||||
npm install # First time only
|
||||
npm run dev
|
||||
|
||||
# Frontend will be available at:
|
||||
# - Application: http://localhost:6001/video-accessibility
|
||||
```
|
||||
|
||||
#### Useful Commands
|
||||
```bash
|
||||
# View logs
|
||||
docker compose logs -f api # API logs
|
||||
docker compose logs -f worker # Worker logs
|
||||
docker compose logs -f # All logs
|
||||
|
||||
# Restart a service
|
||||
docker compose restart api
|
||||
docker compose restart worker
|
||||
|
||||
# Rebuild and restart (after code changes)
|
||||
./scripts/run-local.sh --rebuild
|
||||
|
||||
# Stop all services
|
||||
./scripts/run-local.sh --stop
|
||||
# or
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Alternative: Native Development (Without Docker)
|
||||
|
||||
For development without Docker, you'll need to run each service manually:
|
||||
|
||||
```bash
|
||||
# Terminal 1: MongoDB
|
||||
mongod --dbpath ./data/db
|
||||
|
||||
# Terminal 2: Redis
|
||||
redis-server
|
||||
|
||||
# Terminal 3: Backend API
|
||||
cd backend
|
||||
poetry install
|
||||
poetry run uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# Frontend (new terminal)
|
||||
# Terminal 4: Celery Worker
|
||||
cd backend
|
||||
poetry run celery -A app.tasks worker --loglevel=info
|
||||
|
||||
# Terminal 5: Frontend
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Worker (new terminal)
|
||||
cd backend
|
||||
poetry run celery -A app.tasks worker --loglevel=info
|
||||
```
|
||||
|
||||
**Note**: The Docker approach is strongly recommended as it ensures consistency and simplifies setup.
|
||||
|
||||
### Testing & Quality
|
||||
```bash
|
||||
# Backend tests + linting
|
||||
|
|
@ -172,8 +228,18 @@ video_accessibility/ # Root monorepo
|
|||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ └── types/ # TypeScript definitions
|
||||
│ ├── tests/ # Unit + E2E tests
|
||||
│ ├── .env.local # Local development config
|
||||
│ └── Dockerfile # Container configuration
|
||||
├── docker-compose.yml # Local development stack
|
||||
├── scripts/
|
||||
│ ├── run-local.sh # Local development startup
|
||||
│ ├── deploy.sh # Production deployment
|
||||
│ ├── full-deploy.sh # Full production rebuild
|
||||
│ └── build-frontend.sh # Frontend build script
|
||||
├── docker-compose.yml # Base Docker configuration
|
||||
├── docker-compose.local.yml # Local development overrides
|
||||
├── docker-compose.prod.yml # Production overrides
|
||||
├── .env.local # Local environment variables
|
||||
├── .env.production # Production environment variables
|
||||
├── CLAUDE.md # Development guidelines
|
||||
└── video_accessibility_development_plan.txt # Complete specification
|
||||
```
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ async def list_users(
|
|||
email=user_doc["email"],
|
||||
full_name=user_doc["full_name"],
|
||||
role=user_doc["role"],
|
||||
auth_provider=user_doc.get("auth_provider", "local"),
|
||||
is_active=user_doc["is_active"],
|
||||
created_at=user_doc.get("created_at", datetime.utcnow()).isoformat()
|
||||
))
|
||||
|
|
@ -91,6 +92,7 @@ async def get_user(
|
|||
email=user_doc["email"],
|
||||
full_name=user_doc["full_name"],
|
||||
role=user_doc["role"],
|
||||
auth_provider=user_doc.get("auth_provider", "local"),
|
||||
is_active=user_doc["is_active"],
|
||||
created_at=user_doc.get("created_at", datetime.utcnow()).isoformat()
|
||||
)
|
||||
|
|
@ -119,6 +121,7 @@ async def create_user(
|
|||
"hashed_password": get_password_hash(user_data.password),
|
||||
"full_name": user_data.full_name,
|
||||
"role": user_data.role.value,
|
||||
"auth_provider": "local",
|
||||
"is_active": True,
|
||||
"created_at": datetime.utcnow(),
|
||||
"updated_at": datetime.utcnow()
|
||||
|
|
@ -130,12 +133,13 @@ async def create_user(
|
|||
app_metrics.record_auth_attempt("user_created", user_data.role.value)
|
||||
|
||||
logger.info(f"Admin {current_user.id} created user {user_id} with role {user_data.role.value}")
|
||||
|
||||
|
||||
return UserResponse(
|
||||
id=user_id,
|
||||
email=user_data.email,
|
||||
full_name=user_data.full_name,
|
||||
role=user_data.role,
|
||||
auth_provider="local",
|
||||
is_active=True,
|
||||
created_at=user_doc["created_at"].isoformat()
|
||||
)
|
||||
|
|
@ -186,12 +190,13 @@ async def update_user(
|
|||
)
|
||||
|
||||
logger.info(f"Admin {current_user.id} updated user {user_id}")
|
||||
|
||||
|
||||
return UserResponse(
|
||||
id=str(result["_id"]),
|
||||
email=result["email"],
|
||||
full_name=result["full_name"],
|
||||
role=result["role"],
|
||||
auth_provider=result.get("auth_provider", "local"),
|
||||
is_active=result["is_active"],
|
||||
created_at=result.get("created_at", datetime.utcnow()).isoformat()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import HTTPBearer
|
||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
|
|
@ -10,8 +11,20 @@ from ...core.security import (
|
|||
decode_token,
|
||||
verify_password,
|
||||
)
|
||||
from ...models.user import User
|
||||
from ...schemas.auth import LoginRequest, LoginResponse, LogoutResponse, RefreshResponse
|
||||
from ...models.user import User, AuthProvider, UserRole
|
||||
from ...schemas.auth import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
RefreshResponse,
|
||||
MicrosoftLoginRequest,
|
||||
MicrosoftLoginResponse,
|
||||
)
|
||||
from ...services.microsoft_auth import (
|
||||
get_microsoft_auth_service,
|
||||
MicrosoftTokenValidationError,
|
||||
MicrosoftAuthError,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
security = HTTPBearer()
|
||||
|
|
@ -41,8 +54,15 @@ async def login(
|
|||
|
||||
user = User(**user_doc)
|
||||
|
||||
# Check if user uses Microsoft authentication
|
||||
if user.auth_provider == AuthProvider.MICROSOFT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This account uses Microsoft authentication. Please sign in with Microsoft.",
|
||||
)
|
||||
|
||||
# Verify password
|
||||
if not verify_password(login_data.password, user.hashed_password):
|
||||
if not user.hashed_password or not verify_password(login_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
|
|
@ -80,6 +100,120 @@ async def login(
|
|||
client.close()
|
||||
|
||||
|
||||
@router.post("/microsoft", response_model=MicrosoftLoginResponse)
|
||||
async def microsoft_login(
|
||||
login_data: MicrosoftLoginRequest,
|
||||
response: Response,
|
||||
):
|
||||
"""Authenticate user with Microsoft ID token.
|
||||
|
||||
This endpoint validates the Microsoft ID token, finds or creates the user,
|
||||
and returns JWT tokens for API access.
|
||||
"""
|
||||
print(f"MICROSOFT LOGIN: Starting Microsoft authentication")
|
||||
|
||||
# Create database connection
|
||||
client = AsyncIOMotorClient(settings.mongodb_uri)
|
||||
db = client[settings.mongodb_db]
|
||||
|
||||
try:
|
||||
# Validate Microsoft token
|
||||
microsoft_auth = get_microsoft_auth_service()
|
||||
try:
|
||||
user_info = microsoft_auth.validate_token(login_data.id_token)
|
||||
print(f"MICROSOFT LOGIN: Token validated for {user_info.email}")
|
||||
except MicrosoftTokenValidationError as e:
|
||||
print(f"MICROSOFT LOGIN ERROR: Token validation failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Microsoft authentication failed: {str(e)}",
|
||||
)
|
||||
except MicrosoftAuthError as e:
|
||||
print(f"MICROSOFT LOGIN ERROR: Authentication error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Microsoft authentication service error",
|
||||
)
|
||||
|
||||
# Find or create user
|
||||
user_doc = await db.users.find_one({"email": user_info.email})
|
||||
|
||||
if user_doc:
|
||||
# User exists
|
||||
user = User(**user_doc)
|
||||
print(f"MICROSOFT LOGIN: Existing user found: {user.id}")
|
||||
|
||||
# Update auth_provider if user is switching from local to Microsoft
|
||||
if user.auth_provider == AuthProvider.LOCAL:
|
||||
print(f"MICROSOFT LOGIN: Updating user to Microsoft auth provider")
|
||||
await db.users.update_one(
|
||||
{"_id": user_doc["_id"]},
|
||||
{
|
||||
"$set": {
|
||||
"auth_provider": AuthProvider.MICROSOFT.value,
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
}
|
||||
)
|
||||
user.auth_provider = AuthProvider.MICROSOFT
|
||||
|
||||
else:
|
||||
# Create new user
|
||||
print(f"MICROSOFT LOGIN: Creating new user for {user_info.email}")
|
||||
new_user_id = f"ms-{user_info.sub[:20]}" # Use Microsoft sub as ID
|
||||
new_user = {
|
||||
"_id": new_user_id,
|
||||
"email": user_info.email,
|
||||
"full_name": user_info.name,
|
||||
"hashed_password": None, # No password for Microsoft users
|
||||
"role": UserRole.CLIENT.value,
|
||||
"auth_provider": AuthProvider.MICROSOFT.value,
|
||||
"is_active": True,
|
||||
"created_at": datetime.utcnow(),
|
||||
"updated_at": datetime.utcnow(),
|
||||
}
|
||||
|
||||
await db.users.insert_one(new_user)
|
||||
user = User(**new_user)
|
||||
print(f"MICROSOFT LOGIN: New user created: {user.id}")
|
||||
|
||||
# Check if user is active
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User account is disabled",
|
||||
)
|
||||
|
||||
# Create JWT tokens
|
||||
access_token = create_access_token(subject=str(user.id))
|
||||
refresh_token = create_refresh_token(subject=str(user.id))
|
||||
|
||||
# Set refresh token as HttpOnly cookie
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite=settings.cookie_samesite,
|
||||
domain=settings.cookie_domain if settings.app_env == "prod" else None,
|
||||
max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60,
|
||||
)
|
||||
|
||||
print(f"MICROSOFT LOGIN: Authentication successful for {user.email}")
|
||||
return MicrosoftLoginResponse(
|
||||
access_token=access_token,
|
||||
user_id=str(user.id),
|
||||
role=user.role if isinstance(user.role, str) else user.role.value,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
auth_provider=user.auth_provider,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Close database connection
|
||||
client.close()
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshResponse)
|
||||
async def refresh_token(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -58,12 +58,22 @@ class Settings(BaseSettings):
|
|||
email_from: str
|
||||
client_base_url: str
|
||||
|
||||
# Microsoft Authentication (Azure AD)
|
||||
azure_client_id: str = ""
|
||||
azure_authority: str = ""
|
||||
azure_redirect_uri: str = ""
|
||||
|
||||
# Observability
|
||||
sentry_dsn: str = ""
|
||||
otel_exporter_otlp_endpoint: str = ""
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:5174", "http://localhost:3000"]
|
||||
# CORS - comma-separated list of allowed origins
|
||||
cors_origins: str = "http://localhost:5173,http://localhost:5174,http://localhost:3000,http://localhost:6001"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
"""Parse CORS origins from comma-separated string to list."""
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ app = FastAPI(
|
|||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
allow_headers=["*"],
|
||||
|
|
@ -132,14 +132,14 @@ async def cors_error_handler(request, call_next):
|
|||
|
||||
# Always add CORS headers for allowed origins
|
||||
origin = request.headers.get("origin")
|
||||
if origin and origin in settings.cors_origins:
|
||||
if origin and origin in settings.cors_origins_list:
|
||||
response.headers["access-control-allow-origin"] = origin
|
||||
response.headers["access-control-allow-credentials"] = "true"
|
||||
# Add other necessary CORS headers for error responses
|
||||
if response.status_code >= 400:
|
||||
response.headers["access-control-allow-methods"] = "GET, POST, PUT, PATCH, DELETE"
|
||||
response.headers["access-control-allow-headers"] = "*"
|
||||
|
||||
|
||||
return response
|
||||
|
||||
# Global exception handler to ensure CORS headers on all errors
|
||||
|
|
@ -153,12 +153,12 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
|||
|
||||
# Add CORS headers
|
||||
origin = request.headers.get("origin")
|
||||
if origin and origin in settings.cors_origins:
|
||||
if origin and origin in settings.cors_origins_list:
|
||||
response.headers["access-control-allow-origin"] = origin
|
||||
response.headers["access-control-allow-credentials"] = "true"
|
||||
response.headers["access-control-allow-methods"] = "GET, POST, PUT, PATCH, DELETE"
|
||||
response.headers["access-control-allow-headers"] = "*"
|
||||
|
||||
|
||||
return response
|
||||
|
||||
# Global exception handler for validation errors
|
||||
|
|
@ -172,7 +172,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||
|
||||
# Add CORS headers
|
||||
origin = request.headers.get("origin")
|
||||
if origin and origin in settings.cors_origins:
|
||||
if origin and origin in settings.cors_origins_list:
|
||||
response.headers["access-control-allow-origin"] = origin
|
||||
response.headers["access-control-allow-credentials"] = "true"
|
||||
response.headers["access-control-allow-methods"] = "GET, POST, PUT, PATCH, DELETE"
|
||||
|
|
@ -204,7 +204,7 @@ async def general_exception_handler(request: Request, exc: Exception):
|
|||
|
||||
# Add CORS headers
|
||||
origin = request.headers.get("origin")
|
||||
if origin and origin in settings.cors_origins:
|
||||
if origin and origin in settings.cors_origins_list:
|
||||
response.headers["access-control-allow-origin"] = origin
|
||||
response.headers["access-control-allow-credentials"] = "true"
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
"""Add auth_provider field to users collection."""
|
||||
|
||||
from app.migrations.migrator import Migration
|
||||
|
||||
|
||||
class Migration(Migration):
|
||||
"""Add auth_provider field to support Microsoft authentication."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.version = "2025-01-10-000000"
|
||||
self.description = "Add auth_provider field to users collection"
|
||||
|
||||
async def up(self) -> None:
|
||||
"""Add auth_provider field to all users."""
|
||||
|
||||
# Add auth_provider field to all existing users (default to 'local')
|
||||
result = await self.db.users.update_many(
|
||||
{"auth_provider": {"$exists": False}},
|
||||
{"$set": {"auth_provider": "local"}}
|
||||
)
|
||||
|
||||
print(f"✅ Updated {result.modified_count} users with auth_provider='local'")
|
||||
|
||||
# Create index on auth_provider for faster queries
|
||||
await self.db.users.create_index([("auth_provider", 1)])
|
||||
print(f"✅ Created index on auth_provider field")
|
||||
|
||||
print(f"✅ Applied migration {self.version}: {self.description}")
|
||||
|
||||
async def down(self) -> None:
|
||||
"""Remove auth_provider field from all users."""
|
||||
|
||||
# Drop the index
|
||||
try:
|
||||
await self.db.users.drop_index("auth_provider_1")
|
||||
print(f"✅ Dropped index on auth_provider field")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not drop index: {e}")
|
||||
|
||||
# Remove auth_provider field from all users
|
||||
result = await self.db.users.update_many(
|
||||
{},
|
||||
{"$unset": {"auth_provider": ""}}
|
||||
)
|
||||
|
||||
print(f"✅ Removed auth_provider field from {result.modified_count} users")
|
||||
print(f"⚠️ Rolled back migration {self.version}: {self.description}")
|
||||
|
|
@ -24,12 +24,18 @@ class UserRole(str, Enum):
|
|||
ADMIN = "admin"
|
||||
|
||||
|
||||
class AuthProvider(str, Enum):
|
||||
LOCAL = "local"
|
||||
MICROSOFT = "microsoft"
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: Optional[PyObjectId] = Field(None, alias="_id")
|
||||
email: EmailStr
|
||||
hashed_password: str
|
||||
hashed_password: Optional[str] = None # Optional for Microsoft users
|
||||
full_name: str
|
||||
role: UserRole = UserRole.CLIENT
|
||||
auth_provider: AuthProvider = AuthProvider.LOCAL
|
||||
is_active: bool = True
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from ..models.user import UserRole
|
||||
from ..models.user import UserRole, AuthProvider
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
|
|
@ -15,6 +15,22 @@ class LoginResponse(BaseModel):
|
|||
role: str
|
||||
|
||||
|
||||
class MicrosoftLoginRequest(BaseModel):
|
||||
"""Request schema for Microsoft authentication."""
|
||||
id_token: str
|
||||
|
||||
|
||||
class MicrosoftLoginResponse(BaseModel):
|
||||
"""Response schema for Microsoft authentication."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user_id: str
|
||||
role: str
|
||||
email: str
|
||||
full_name: str
|
||||
auth_provider: AuthProvider
|
||||
|
||||
|
||||
class RefreshResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
|
@ -34,6 +50,7 @@ class UserResponse(BaseModel):
|
|||
email: EmailStr
|
||||
full_name: str
|
||||
role: UserRole
|
||||
auth_provider: AuthProvider
|
||||
is_active: bool
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
|
|
|||
220
backend/app/services/microsoft_auth.py
Normal file
220
backend/app/services/microsoft_auth.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""Microsoft Authentication Service
|
||||
|
||||
Validates Microsoft ID tokens and extracts user information.
|
||||
"""
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
import requests
|
||||
from jose import jwt, JWTError
|
||||
from jose.exceptions import JWKError
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from ..core.config import settings
|
||||
from ..core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MicrosoftUserInfo(BaseModel):
|
||||
"""User information extracted from Microsoft ID token."""
|
||||
email: EmailStr
|
||||
name: str
|
||||
sub: str # Microsoft user ID
|
||||
tid: str # Tenant ID
|
||||
email_verified: bool = True
|
||||
|
||||
|
||||
class MicrosoftAuthError(Exception):
|
||||
"""Base exception for Microsoft authentication errors."""
|
||||
pass
|
||||
|
||||
|
||||
class MicrosoftTokenValidationError(MicrosoftAuthError):
|
||||
"""Raised when token validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class MicrosoftAuthService:
|
||||
"""Service for Microsoft authentication operations."""
|
||||
|
||||
def __init__(self):
|
||||
self.client_id = settings.azure_client_id
|
||||
self.authority = settings.azure_authority
|
||||
|
||||
# Extract tenant ID from authority URL
|
||||
# Format: https://login.microsoftonline.com/{tenant_id}
|
||||
self.tenant_id = self.authority.rstrip('/').split('/')[-1]
|
||||
|
||||
# Microsoft's OpenID configuration endpoint
|
||||
self.openid_config_url = f"{self.authority}/v2.0/.well-known/openid-configuration"
|
||||
|
||||
# Cache for JWKS (public keys)
|
||||
self._jwks_cache: Optional[Dict] = None
|
||||
self._jwks_cache_time: float = 0
|
||||
self._jwks_cache_ttl: int = 3600 # Cache for 1 hour
|
||||
|
||||
def _get_openid_config(self) -> Dict:
|
||||
"""Fetch OpenID Connect configuration from Microsoft."""
|
||||
try:
|
||||
response = requests.get(self.openid_config_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch OpenID configuration: {e}")
|
||||
raise MicrosoftAuthError("Failed to fetch Microsoft authentication configuration")
|
||||
|
||||
def _get_jwks(self, force_refresh: bool = False) -> Dict:
|
||||
"""Fetch JSON Web Key Set (JWKS) from Microsoft.
|
||||
|
||||
Args:
|
||||
force_refresh: Force refresh even if cache is valid
|
||||
|
||||
Returns:
|
||||
JWKS dictionary with public keys
|
||||
"""
|
||||
# Check cache
|
||||
current_time = time.time()
|
||||
if (not force_refresh and
|
||||
self._jwks_cache and
|
||||
(current_time - self._jwks_cache_time) < self._jwks_cache_ttl):
|
||||
return self._jwks_cache
|
||||
|
||||
try:
|
||||
# Get JWKS URI from OpenID configuration
|
||||
config = self._get_openid_config()
|
||||
jwks_uri = config.get('jwks_uri')
|
||||
|
||||
if not jwks_uri:
|
||||
raise MicrosoftAuthError("JWKS URI not found in OpenID configuration")
|
||||
|
||||
# Fetch JWKS
|
||||
response = requests.get(jwks_uri, timeout=10)
|
||||
response.raise_for_status()
|
||||
jwks = response.json()
|
||||
|
||||
# Update cache
|
||||
self._jwks_cache = jwks
|
||||
self._jwks_cache_time = current_time
|
||||
|
||||
return jwks
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch JWKS: {e}")
|
||||
raise MicrosoftAuthError("Failed to fetch Microsoft public keys")
|
||||
|
||||
def validate_token(self, id_token: str) -> MicrosoftUserInfo:
|
||||
"""Validate Microsoft ID token and extract user information.
|
||||
|
||||
Args:
|
||||
id_token: Microsoft ID token string
|
||||
|
||||
Returns:
|
||||
MicrosoftUserInfo with validated user data
|
||||
|
||||
Raises:
|
||||
MicrosoftTokenValidationError: If token validation fails
|
||||
"""
|
||||
try:
|
||||
# Get JWKS for signature verification
|
||||
jwks = self._get_jwks()
|
||||
|
||||
# Decode token header to get key ID (kid)
|
||||
unverified_header = jwt.get_unverified_header(id_token)
|
||||
kid = unverified_header.get('kid')
|
||||
|
||||
if not kid:
|
||||
raise MicrosoftTokenValidationError("Token header missing 'kid' claim")
|
||||
|
||||
# Find the matching key in JWKS
|
||||
rsa_key = None
|
||||
for key in jwks.get('keys', []):
|
||||
if key.get('kid') == kid:
|
||||
rsa_key = {
|
||||
'kty': key['kty'],
|
||||
'kid': key['kid'],
|
||||
'use': key.get('use'),
|
||||
'n': key['n'],
|
||||
'e': key['e']
|
||||
}
|
||||
break
|
||||
|
||||
if not rsa_key:
|
||||
logger.warning(f"Key ID {kid} not found in JWKS, refreshing cache")
|
||||
# Try refreshing JWKS cache (keys might have been rotated)
|
||||
jwks = self._get_jwks(force_refresh=True)
|
||||
for key in jwks.get('keys', []):
|
||||
if key.get('kid') == kid:
|
||||
rsa_key = {
|
||||
'kty': key['kty'],
|
||||
'kid': key['kid'],
|
||||
'use': key.get('use'),
|
||||
'n': key['n'],
|
||||
'e': key['e']
|
||||
}
|
||||
break
|
||||
|
||||
if not rsa_key:
|
||||
raise MicrosoftTokenValidationError(f"Unable to find key with ID: {kid}")
|
||||
|
||||
# Validate token signature and claims
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
id_token,
|
||||
rsa_key,
|
||||
algorithms=['RS256'],
|
||||
audience=self.client_id,
|
||||
issuer=f"https://login.microsoftonline.com/{self.tenant_id}/v2.0"
|
||||
)
|
||||
except JWTError as e:
|
||||
raise MicrosoftTokenValidationError(f"Token validation failed: {str(e)}")
|
||||
|
||||
# Extract required claims
|
||||
email = payload.get('email') or payload.get('preferred_username')
|
||||
if not email:
|
||||
raise MicrosoftTokenValidationError("Token missing email claim")
|
||||
|
||||
name = payload.get('name')
|
||||
if not name:
|
||||
# Fallback to email if name not provided
|
||||
name = email.split('@')[0]
|
||||
|
||||
sub = payload.get('sub')
|
||||
if not sub:
|
||||
raise MicrosoftTokenValidationError("Token missing 'sub' claim")
|
||||
|
||||
tid = payload.get('tid')
|
||||
if not tid:
|
||||
raise MicrosoftTokenValidationError("Token missing 'tid' claim")
|
||||
|
||||
# Check if email is verified (Microsoft tokens are considered verified)
|
||||
email_verified = payload.get('email_verified', True)
|
||||
|
||||
# Create user info object
|
||||
user_info = MicrosoftUserInfo(
|
||||
email=email,
|
||||
name=name,
|
||||
sub=sub,
|
||||
tid=tid,
|
||||
email_verified=email_verified
|
||||
)
|
||||
|
||||
logger.info(f"Successfully validated Microsoft token for user: {email}")
|
||||
return user_info
|
||||
|
||||
except JWKError as e:
|
||||
logger.error(f"JWK error during token validation: {e}")
|
||||
raise MicrosoftTokenValidationError(f"Key processing error: {str(e)}")
|
||||
except Exception as e:
|
||||
if isinstance(e, (MicrosoftAuthError, MicrosoftTokenValidationError)):
|
||||
raise
|
||||
logger.error(f"Unexpected error during token validation: {e}")
|
||||
raise MicrosoftTokenValidationError(f"Token validation failed: {str(e)}")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
microsoft_auth_service = MicrosoftAuthService()
|
||||
|
||||
|
||||
def get_microsoft_auth_service() -> MicrosoftAuthService:
|
||||
"""Get Microsoft authentication service instance."""
|
||||
return microsoft_auth_service
|
||||
69
docker-compose.local.yml
Normal file
69
docker-compose.local.yml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# =============================================================================
|
||||
# Docker Compose Local Development Overrides
|
||||
# =============================================================================
|
||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.local.yml up -d
|
||||
# Or use: ./scripts/run-local.sh
|
||||
# =============================================================================
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# MongoDB - Local Development Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
mongodb:
|
||||
# No resource limits for local development
|
||||
# Expose port for direct access (optional, for debugging with MongoDB Compass)
|
||||
ports:
|
||||
- "27017:27017"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis - Local Development Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
redis:
|
||||
# No resource limits for local development
|
||||
# Expose port for direct access (optional, for debugging with Redis CLI)
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API - Local Development Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
api:
|
||||
# No resource limits for local development
|
||||
# Build without cache for fresh builds
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: api
|
||||
# Optional: Uncomment to disable cache during development
|
||||
# args:
|
||||
# - BUILDKIT_INLINE_CACHE=0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker - Local Development Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
worker:
|
||||
# No resource limits for local development
|
||||
# Build without cache for fresh builds
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: worker
|
||||
# Optional: Uncomment to disable cache during development
|
||||
# args:
|
||||
# - BUILDKIT_INLINE_CACHE=0
|
||||
|
||||
# =============================================================================
|
||||
# LOCAL DEVELOPMENT NOTES
|
||||
# =============================================================================
|
||||
# This override file:
|
||||
# - Removes production resource limits
|
||||
# - Exposes MongoDB (27017) and Redis (6379) ports for local tools
|
||||
# - Keeps all volume mounts for data persistence
|
||||
# - Uses same environment variables from .env.local
|
||||
#
|
||||
# To start: ./scripts/run-local.sh
|
||||
# To stop: docker compose down
|
||||
# To view logs: docker compose logs -f [service]
|
||||
# =============================================================================
|
||||
BIN
docs/video accessibility technical documentation 2025-09-09.pdf
Normal file
BIN
docs/video accessibility technical documentation 2025-09-09.pdf
Normal file
Binary file not shown.
2631
docs/video_accessibility_technical_documentation_v2.md
Normal file
2631
docs/video_accessibility_technical_documentation_v2.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,11 @@ VITE_API_BASE_URL=https://ai-sandbox.oliver.solutions/video-accessibility-back
|
|||
# Application environment
|
||||
VITE_APP_ENV=production
|
||||
|
||||
# Microsoft Authentication (Azure AD)
|
||||
VITE_AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
|
||||
VITE_AZURE_AUTHORITY=https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385
|
||||
VITE_AZURE_REDIRECT_URI=https://ai-sandbox.oliver.solutions/video-accessibility/
|
||||
|
||||
# Sentry DSN for error tracking (optional - leave empty to disable)
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
|
|
|
|||
36
frontend/package-lock.json
generated
36
frontend/package-lock.json
generated
|
|
@ -8,6 +8,8 @@
|
|||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.25.0",
|
||||
"@azure/msal-react": "^3.0.20",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
|
|
@ -102,6 +104,40 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@azure/msal-browser": {
|
||||
"version": "4.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.25.0.tgz",
|
||||
"integrity": "sha512-kbL+Ae7/UC62wSzxirZddYeVnHvvkvAnSZkBqL55X+jaSXTAXfngnNsDM5acEWU0Q/SAv3gEQfxO1igWOn87Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-common": "15.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-common": {
|
||||
"version": "15.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.0.tgz",
|
||||
"integrity": "sha512-8oF6nj02qX7eE/6+wFT5NluXRHc05AgdCC3fJnkjiJooq8u7BcLmxaYYSwc2AfEkWRMRi6Eyvvbeqk4U4412Ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-react": {
|
||||
"version": "3.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-3.0.20.tgz",
|
||||
"integrity": "sha512-+mlGe5rzJDe1Feb0BcPwCkcRTIXAUX0mxBnP8hDuzIXrwBAT/iHHl6wcsZ5iKBnMuqOicJhGX5l2/Iwqguom0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@azure/msal-browser": "^4.24.0",
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.25.0",
|
||||
"@azure/msal-react": "^3.0.20",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { QCList } from './routes/admin/QCList';
|
|||
import { QCDetail } from './routes/admin/QCDetail';
|
||||
import { FinalList } from './routes/admin/FinalList';
|
||||
import { FinalDetail } from './routes/admin/FinalDetail';
|
||||
import { UserList } from './routes/admin/UserList';
|
||||
import { UserDetail } from './routes/admin/UserDetail';
|
||||
import { Downloads } from './routes/Downloads';
|
||||
import { RequireAuth } from './components/Auth/RequireAuth';
|
||||
import { RoleGate } from './components/Auth/RoleGate';
|
||||
|
|
@ -91,6 +93,20 @@ function AppContent() {
|
|||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
} />
|
||||
<Route path="/admin/users" element={
|
||||
<AuthenticatedRoute>
|
||||
<RoleGate allowedRoles={['admin']}>
|
||||
<UserList />
|
||||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
} />
|
||||
<Route path="/admin/users/:id" element={
|
||||
<AuthenticatedRoute>
|
||||
<RoleGate allowedRoles={['admin']}>
|
||||
<UserDetail />
|
||||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
} />
|
||||
<Route path="/downloads/:id" element={
|
||||
<AuthenticatedRoute>
|
||||
<Downloads />
|
||||
|
|
|
|||
|
|
@ -90,7 +90,20 @@ export function Navbar({ onMobileMenuClick }: NavbarProps) {
|
|||
</svg>
|
||||
Profile Settings
|
||||
</Link>
|
||||
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin/users"
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
User Management
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/help"
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
|
|
|
|||
|
|
@ -132,14 +132,15 @@ export function useJobStatusWebSocket(
|
|||
}, [debug]);
|
||||
|
||||
const getWebSocketUrl = useCallback(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
// Get API base URL from environment
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
const apiUrl = new URL(apiBaseUrl);
|
||||
|
||||
// Get API base URL from environment and extract the path
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const apiPath = apiBaseUrl ? new URL(apiBaseUrl).pathname : '';
|
||||
// Use wss:// for https, ws:// for http
|
||||
const protocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = apiUrl.host; // Use backend host, not window.location.host
|
||||
|
||||
const basePath = `${apiPath}/api/v1/ws/jobs`;
|
||||
const basePath = `/api/v1/ws/jobs`;
|
||||
const path = jobId ? `${basePath}/${jobId}` : basePath;
|
||||
const token = encodeURIComponent(accessToken || '');
|
||||
return `${protocol}//${host}${path}?token=${token}`;
|
||||
|
|
|
|||
87
frontend/src/hooks/useUsers.ts
Normal file
87
frontend/src/hooks/useUsers.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../lib/api';
|
||||
import type {
|
||||
CreateUserRequest,
|
||||
UpdateUserRequest,
|
||||
} from '../types/api';
|
||||
|
||||
// Query hooks
|
||||
export function useUsers(filters?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
role?: string;
|
||||
active_only?: boolean;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ['users', filters],
|
||||
queryFn: () => apiClient.listUsers(filters),
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useUser(userId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['users', userId],
|
||||
queryFn: () => apiClient.getUser(userId),
|
||||
enabled: !!userId,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminStats() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'stats'],
|
||||
queryFn: () => apiClient.getAdminStats(),
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
// Mutation hooks
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateUserRequest) => apiClient.createUser(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, data }: { userId: string; data: UpdateUserRequest }) =>
|
||||
apiClient.updateUser(userId, data),
|
||||
onSuccess: (_, { userId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users', userId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeactivateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => apiClient.deactivateUser(userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetUserPassword() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => apiClient.resetUserPassword(userId),
|
||||
onSuccess: (_, userId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users', userId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
LoginRequest,
|
||||
LoginResponse,
|
||||
RefreshResponse,
|
||||
MicrosoftLoginResponse,
|
||||
Job,
|
||||
JobCreateRequest,
|
||||
JobListResponse,
|
||||
|
|
@ -14,6 +15,12 @@ import type {
|
|||
BulkDeleteRequest,
|
||||
BulkDeleteResponse,
|
||||
JobDeleteResponse,
|
||||
User,
|
||||
UserListResponse,
|
||||
CreateUserRequest,
|
||||
UpdateUserRequest,
|
||||
ResetPasswordResponse,
|
||||
AdminStatsResponse,
|
||||
} from '../types/api';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
|
@ -97,6 +104,12 @@ class ApiClient {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
async loginWithMicrosoft(idToken: string): Promise<MicrosoftLoginResponse> {
|
||||
const response = await this.client.post('/auth/microsoft', { id_token: idToken });
|
||||
this.setAccessToken(response.data.access_token);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async refresh(): Promise<RefreshResponse> {
|
||||
const response = await this.client.post('/auth/refresh');
|
||||
this.setAccessToken(response.data.access_token);
|
||||
|
|
@ -217,6 +230,53 @@ class ApiClient {
|
|||
const response = await this.client.post(`/admin/maintenance/reprocess-job/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// User Management endpoints
|
||||
async listUsers(filters?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
role?: string;
|
||||
active_only?: boolean;
|
||||
}): Promise<UserListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.page) params.append('page', filters.page.toString());
|
||||
if (filters?.size) params.append('size', filters.size.toString());
|
||||
if (filters?.role) params.append('role', filters.role);
|
||||
if (filters?.active_only !== undefined) params.append('active_only', filters.active_only.toString());
|
||||
|
||||
const response = await this.client.get(`/admin/users?${params.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getUser(userId: string): Promise<User> {
|
||||
const response = await this.client.get(`/admin/users/${userId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createUser(data: CreateUserRequest): Promise<User> {
|
||||
const response = await this.client.post('/admin/users', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateUser(userId: string, data: UpdateUserRequest): Promise<User> {
|
||||
const response = await this.client.patch(`/admin/users/${userId}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deactivateUser(userId: string): Promise<{ message: string }> {
|
||||
const response = await this.client.delete(`/admin/users/${userId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async resetUserPassword(userId: string): Promise<ResetPasswordResponse> {
|
||||
const response = await this.client.post(`/admin/users/${userId}/password/reset`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAdminStats(): Promise<AdminStatsResponse> {
|
||||
const response = await this.client.get('/admin/stats');
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
|
|
|||
100
frontend/src/lib/msalConfig.ts
Normal file
100
frontend/src/lib/msalConfig.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Microsoft Authentication Library (MSAL) Configuration
|
||||
*
|
||||
* Configures MSAL for Azure AD authentication with PKCE flow.
|
||||
* PKCE is automatically enabled for browser-based SPAs.
|
||||
*/
|
||||
|
||||
import { LogLevel } from '@azure/msal-browser';
|
||||
import type { Configuration } from '@azure/msal-browser';
|
||||
|
||||
/**
|
||||
* MSAL Configuration
|
||||
*
|
||||
* Documentation: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/configuration.md
|
||||
*/
|
||||
export const msalConfig: Configuration = {
|
||||
auth: {
|
||||
clientId: import.meta.env.VITE_AZURE_CLIENT_ID || '',
|
||||
authority: import.meta.env.VITE_AZURE_AUTHORITY || '',
|
||||
redirectUri: import.meta.env.VITE_AZURE_REDIRECT_URI || window.location.origin,
|
||||
postLogoutRedirectUri: import.meta.env.VITE_AZURE_REDIRECT_URI || window.location.origin,
|
||||
navigateToLoginRequestUrl: false, // We handle navigation ourselves
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'sessionStorage', // More secure than localStorage
|
||||
storeAuthStateInCookie: false, // Set to true for IE 11 or Edge
|
||||
},
|
||||
system: {
|
||||
loggerOptions: {
|
||||
loggerCallback: (level: LogLevel, message: string, containsPii: boolean) => {
|
||||
if (containsPii) {
|
||||
return;
|
||||
}
|
||||
switch (level) {
|
||||
case LogLevel.Error:
|
||||
console.error('[MSAL]', message);
|
||||
return;
|
||||
case LogLevel.Info:
|
||||
console.info('[MSAL]', message);
|
||||
return;
|
||||
case LogLevel.Verbose:
|
||||
console.debug('[MSAL]', message);
|
||||
return;
|
||||
case LogLevel.Warning:
|
||||
console.warn('[MSAL]', message);
|
||||
return;
|
||||
}
|
||||
},
|
||||
logLevel: import.meta.env.DEV ? LogLevel.Info : LogLevel.Error,
|
||||
piiLoggingEnabled: false,
|
||||
},
|
||||
allowNativeBroker: false, // Disable WAM broker (Windows)
|
||||
windowHashTimeout: 60000,
|
||||
iframeHashTimeout: 6000,
|
||||
loadFrameTimeout: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Scopes for login request
|
||||
*
|
||||
* openid: Required for OIDC
|
||||
* profile: Get user's display name
|
||||
* email: Get user's email address
|
||||
*/
|
||||
export const loginRequest = {
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Scopes for silent token acquisition
|
||||
*/
|
||||
export const tokenRequest = {
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate MSAL configuration
|
||||
*
|
||||
* Throws error if required environment variables are missing
|
||||
*/
|
||||
export function validateMsalConfig(): void {
|
||||
const clientId = import.meta.env.VITE_AZURE_CLIENT_ID;
|
||||
const authority = import.meta.env.VITE_AZURE_AUTHORITY;
|
||||
const redirectUri = import.meta.env.VITE_AZURE_REDIRECT_URI;
|
||||
|
||||
if (!clientId) {
|
||||
throw new Error('VITE_AZURE_CLIENT_ID is not configured');
|
||||
}
|
||||
|
||||
if (!authority) {
|
||||
throw new Error('VITE_AZURE_AUTHORITY is not configured');
|
||||
}
|
||||
|
||||
if (!redirectUri) {
|
||||
console.warn('VITE_AZURE_REDIRECT_URI is not configured, using window.location.origin');
|
||||
}
|
||||
|
||||
console.log('[MSAL] Configuration validated successfully');
|
||||
}
|
||||
|
|
@ -1,8 +1,18 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import * as Sentry from '@sentry/react'
|
||||
import { PublicClientApplication } from '@azure/msal-browser'
|
||||
import { MsalProvider } from '@azure/msal-react'
|
||||
import './styles/index.css'
|
||||
import App from './App.tsx'
|
||||
import { msalConfig, validateMsalConfig } from './lib/msalConfig'
|
||||
|
||||
// Initialize MSAL (Microsoft Authentication Library)
|
||||
validateMsalConfig();
|
||||
const msalInstance = new PublicClientApplication(msalConfig);
|
||||
|
||||
// Initialize MSAL instance
|
||||
await msalInstance.initialize();
|
||||
|
||||
// Initialize Sentry
|
||||
if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
|
|
@ -31,6 +41,8 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
|||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<MsalProvider instance={msalInstance}>
|
||||
<App />
|
||||
</MsalProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMsal } from '@azure/msal-react';
|
||||
import { useAuthStore } from '../lib/auth';
|
||||
import { loginRequest } from '../lib/msalConfig';
|
||||
import { apiClient } from '../lib/api';
|
||||
|
||||
export function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [microsoftLoading, setMicrosoftLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { login, isLoading } = useAuthStore();
|
||||
const { login, isLoading, setUser } = useAuthStore();
|
||||
const { instance } = useMsal();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -29,6 +34,50 @@ export function Login() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleMicrosoftLogin = async () => {
|
||||
setError('');
|
||||
setMicrosoftLoading(true);
|
||||
|
||||
try {
|
||||
// Login with popup
|
||||
const response = await instance.loginPopup(loginRequest);
|
||||
|
||||
// Get ID token from Microsoft response
|
||||
const idToken = response.idToken;
|
||||
|
||||
// Send ID token to our backend for validation and user creation
|
||||
const loginResponse = await apiClient.loginWithMicrosoft(idToken);
|
||||
|
||||
// Set user in auth store
|
||||
setUser({
|
||||
id: loginResponse.user_id,
|
||||
email: loginResponse.email,
|
||||
full_name: loginResponse.full_name,
|
||||
role: loginResponse.role as 'client' | 'reviewer' | 'admin',
|
||||
auth_provider: loginResponse.auth_provider,
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log('Microsoft login successful');
|
||||
navigate('/');
|
||||
} catch (err: unknown) {
|
||||
console.error('Microsoft login failed:', err);
|
||||
const error = err as { code?: string; response?: { data?: { detail?: string; message?: string } }; message?: string };
|
||||
|
||||
if (error.code === 'ERR_NETWORK' || !error.response) {
|
||||
setError('Network error: Cannot connect to server. Please check if the backend is running.');
|
||||
} else if (error.message?.includes('user_cancelled') || error.message?.includes('AADB2C90091')) {
|
||||
// User cancelled the popup - don't show error
|
||||
setError('');
|
||||
} else {
|
||||
setError(error.response?.data?.detail || error.response?.data?.message || error.message || 'Microsoft authentication failed. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setMicrosoftLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left side - Branding */}
|
||||
|
|
@ -139,7 +188,7 @@ export function Login() {
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || microsoftLoading}
|
||||
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 px-4 rounded-lg font-semibold hover:from-blue-600 hover:to-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 transform hover:scale-105 disabled:transform-none shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{isLoading ? (
|
||||
|
|
@ -153,6 +202,41 @@ export function Login() {
|
|||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Microsoft Sign In Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMicrosoftLogin}
|
||||
disabled={isLoading || microsoftLoading}
|
||||
className="w-full bg-white border-2 border-gray-300 text-gray-700 py-3 px-4 rounded-lg font-semibold hover:bg-gray-50 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
>
|
||||
{microsoftLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-gray-700 mr-2"></div>
|
||||
Signing in with Microsoft...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#F25022"/>
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00A4EF"/>
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7FBA00"/>
|
||||
<rect x="11" y="11" width="9" height="9" fill="#FFB900"/>
|
||||
</svg>
|
||||
Sign in with Microsoft
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<div className="text-center text-sm text-gray-600 space-y-2">
|
||||
<p className="font-medium">Demo Credentials:</p>
|
||||
|
|
|
|||
291
frontend/src/routes/admin/UserDetail.tsx
Normal file
291
frontend/src/routes/admin/UserDetail.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useUser, useUpdateUser, useResetUserPassword } from '../../hooks/useUsers';
|
||||
import { useToastContext } from '../../contexts/ToastContext';
|
||||
import type { UserRole, UpdateUserRequest } from '../../types/api';
|
||||
|
||||
export function UserDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToastContext();
|
||||
|
||||
const { data: user, isLoading, error } = useUser(id!);
|
||||
const updateUserMutation = useUpdateUser();
|
||||
const resetPasswordMutation = useResetUserPassword();
|
||||
|
||||
const [formData, setFormData] = useState<UpdateUserRequest>({
|
||||
email: '',
|
||||
full_name: '',
|
||||
role: 'client' as UserRole,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
// Initialize form when user data loads
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-8"></div>
|
||||
<div className="space-y-4">
|
||||
<div className="h-12 bg-gray-200 rounded"></div>
|
||||
<div className="h-12 bg-gray-200 rounded"></div>
|
||||
<div className="h-12 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">User Not Found</h1>
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<p className="text-red-600">Failed to load user details.</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/admin/users"
|
||||
className="mt-4 inline-flex items-center text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
← Back to Users
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await updateUserMutation.mutateAsync({
|
||||
userId: id!,
|
||||
data: formData,
|
||||
});
|
||||
toast.toastOnly.success('User updated successfully');
|
||||
navigate('/admin/users');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error && 'response' in error
|
||||
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: undefined;
|
||||
toast.toastOnly.error(errorMessage || 'Failed to update user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!window.confirm(`Are you sure you want to reset password for ${user.email}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await resetPasswordMutation.mutateAsync(id!);
|
||||
toast.toastOnly.success(`Password reset. Temporary password: ${response.temporary_password}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error && 'response' in error
|
||||
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: undefined;
|
||||
toast.toastOnly.error(errorMessage || 'Failed to reset password');
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges =
|
||||
formData.email !== user.email ||
|
||||
formData.full_name !== user.full_name ||
|
||||
formData.role !== user.role ||
|
||||
formData.is_active !== user.is_active;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/admin/users"
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-800 mb-4"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Users
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit User</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Form */}
|
||||
<div className="lg:col-span-2">
|
||||
<form onSubmit={handleSubmit} className="bg-white shadow rounded-lg p-6 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="full_name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="full_name"
|
||||
required
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
required
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as UserRole })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="client">Client</option>
|
||||
<option value="reviewer">Reviewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
|
||||
Active User
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/users')}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!hasChanges || updateUserMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updateUserMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
{/* User Info Card */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">User Information</h3>
|
||||
<dl className="space-y-3">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">User ID</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 font-mono">{user.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Authentication Method</dt>
|
||||
<dd className="mt-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
user.auth_provider === 'microsoft' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{user.auth_provider === 'microsoft' && (
|
||||
<svg className="w-3 h-3 mr-1" viewBox="0 0 21 21" fill="currentColor">
|
||||
<rect x="1" y="1" width="9" height="9"/>
|
||||
</svg>
|
||||
)}
|
||||
{user.auth_provider}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleString() : 'N/A'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd className="mt-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Actions Card */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Actions</h3>
|
||||
<div className="space-y-3">
|
||||
{user.auth_provider === 'local' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleResetPassword}
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-orange-700 bg-orange-50 border border-orange-200 rounded-lg hover:bg-orange-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
{resetPasswordMutation.isPending ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<p className="text-xs text-gray-500">
|
||||
Note: Resetting a password will generate a temporary password that must be shared with the user securely.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-800">Microsoft Authentication</p>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
This user authenticates via Microsoft. Password management is handled by Microsoft Azure AD.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
452
frontend/src/routes/admin/UserList.tsx
Normal file
452
frontend/src/routes/admin/UserList.tsx
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
useUsers,
|
||||
useDeactivateUser,
|
||||
useResetUserPassword,
|
||||
useCreateUser,
|
||||
} from '../../hooks/useUsers';
|
||||
import { useToastContext } from '../../contexts/ToastContext';
|
||||
import type { UserRole, CreateUserRequest } from '../../types/api';
|
||||
|
||||
export function UserList() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [roleFilter, setRoleFilter] = useState<string>('');
|
||||
const [activeOnly, setActiveOnly] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const toast = useToastContext();
|
||||
|
||||
const { data: usersResponse, isLoading, error } = useUsers({
|
||||
page,
|
||||
size: 20,
|
||||
role: roleFilter || undefined,
|
||||
active_only: activeOnly,
|
||||
});
|
||||
|
||||
const deactivateUserMutation = useDeactivateUser();
|
||||
const resetPasswordMutation = useResetUserPassword();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-8"></div>
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">User Management</h1>
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<p className="text-red-600">Failed to load users. Please try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const users = usersResponse?.users || [];
|
||||
const totalPages = Math.ceil((usersResponse?.total || 0) / 20);
|
||||
|
||||
const handleDeactivateUser = async (userId: string, userEmail: string) => {
|
||||
if (!window.confirm(`Are you sure you want to deactivate ${userEmail}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deactivateUserMutation.mutateAsync(userId);
|
||||
toast.toastOnly.success(`User ${userEmail} deactivated successfully`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error && 'response' in error
|
||||
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: undefined;
|
||||
toast.toastOnly.error(errorMessage || 'Failed to deactivate user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (userId: string, userEmail: string) => {
|
||||
if (!window.confirm(`Are you sure you want to reset password for ${userEmail}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await resetPasswordMutation.mutateAsync(userId);
|
||||
// Show the temporary password to the admin
|
||||
toast.toastOnly.success(`Password reset. Temporary password: ${response.temporary_password}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error && 'response' in error
|
||||
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: undefined;
|
||||
toast.toastOnly.error(errorMessage || 'Failed to reset password');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-sm text-gray-700">Role:</label>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => {
|
||||
setRoleFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="text-sm border border-gray-300 rounded px-3 py-1.5"
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="reviewer">Reviewer</option>
|
||||
<option value="client">Client</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activeOnly"
|
||||
checked={activeOnly}
|
||||
onChange={(e) => {
|
||||
setActiveOnly(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="activeOnly" className="text-sm text-gray-700">
|
||||
Active users only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 ml-auto">
|
||||
{usersResponse?.total || 0} user{usersResponse?.total !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users List */}
|
||||
{users.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg className="mx-auto h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No users found</h3>
|
||||
<p className="text-gray-500">Try adjusting your filters or create a new user.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Auth Method
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{user.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{user.full_name}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
user.role === 'admin' ? 'bg-purple-100 text-purple-800' :
|
||||
user.role === 'reviewer' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
user.auth_provider === 'microsoft' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{user.auth_provider === 'microsoft' ? (
|
||||
<svg className="w-3 h-3 mr-1" viewBox="0 0 21 21" fill="currentColor">
|
||||
<rect x="1" y="1" width="9" height="9"/>
|
||||
</svg>
|
||||
) : null}
|
||||
{user.auth_provider}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-3">
|
||||
<Link
|
||||
to={`/admin/users/${user.id}`}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
{user.auth_provider !== 'microsoft' && (
|
||||
<button
|
||||
onClick={() => handleResetPassword(user.id, user.email)}
|
||||
className="text-orange-600 hover:text-orange-900"
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
)}
|
||||
{user.is_active && (
|
||||
<button
|
||||
onClick={() => handleDeactivateUser(user.id, user.email)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
disabled={deactivateUserMutation.isPending}
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200 sm:px-6 mt-4 rounded-lg">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing page <span className="font-medium">{page}</span> of{' '}
|
||||
<span className="font-medium">{totalPages}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateUserModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateModal(false);
|
||||
toast.toastOnly.success('User created successfully');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create User Modal Component
|
||||
function CreateUserModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
const [formData, setFormData] = useState<CreateUserRequest>({
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'client' as UserRole,
|
||||
});
|
||||
const createUserMutation = useCreateUser();
|
||||
const toast = useToastContext();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await createUserMutation.mutateAsync(formData);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error && 'response' in error
|
||||
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: undefined;
|
||||
toast.toastOnly.error(errorMessage || 'Failed to create user');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 flex items-center justify-center">
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200">
|
||||
<h3 className="text-xl font-semibold text-gray-900">Create New User</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="full_name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="full_name"
|
||||
required
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
required
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as UserRole })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="client">Client</option>
|
||||
<option value="reviewer">Reviewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createUserMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createUserMutation.isPending ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,12 +12,14 @@ export type JobStatus =
|
|||
| "completed";
|
||||
|
||||
export type UserRole = "client" | "reviewer" | "admin";
|
||||
export type AuthProvider = "local" | "microsoft";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: UserRole;
|
||||
auth_provider: AuthProvider;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
|
@ -102,6 +104,20 @@ export interface RefreshResponse {
|
|||
full_name: string;
|
||||
}
|
||||
|
||||
export interface MicrosoftLoginRequest {
|
||||
id_token: string;
|
||||
}
|
||||
|
||||
export interface MicrosoftLoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
auth_provider: AuthProvider;
|
||||
}
|
||||
|
||||
export interface JobCreateRequest {
|
||||
title: string;
|
||||
language: string;
|
||||
|
|
@ -148,4 +164,40 @@ export interface BulkDeleteResponse {
|
|||
|
||||
export interface JobDeleteResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// User Management types
|
||||
export interface UserListResponse {
|
||||
users: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
email?: string;
|
||||
full_name?: string;
|
||||
role?: UserRole;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface ResetPasswordResponse {
|
||||
message: string;
|
||||
temporary_password: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface AdminStatsResponse {
|
||||
total_users: number;
|
||||
total_jobs: number;
|
||||
jobs_by_status: Record<string, number>;
|
||||
active_jobs_today: number;
|
||||
avg_processing_time_hours: number;
|
||||
}
|
||||
|
|
@ -5,12 +5,13 @@ import react from '@vitejs/plugin-react'
|
|||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// Base path for production deployment in Apache subdirectory
|
||||
// Base path: consistent across dev and production
|
||||
base: '/video-accessibility/',
|
||||
server: {
|
||||
port: 6001, // Local development port
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://localhost:8003', // Docker container exposed port
|
||||
changeOrigin: true,
|
||||
ws: true, // Enable WebSocket proxying
|
||||
},
|
||||
|
|
|
|||
243
scripts/run-local.sh
Executable file
243
scripts/run-local.sh
Executable file
|
|
@ -0,0 +1,243 @@
|
|||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Local Development Startup Script for Accessible Video Platform
|
||||
# =============================================================================
|
||||
# This script starts backend services (API, Worker, MongoDB, Redis) in Docker
|
||||
# Frontend should be run separately: cd frontend && npm run dev
|
||||
#
|
||||
# Usage: ./scripts/run-local.sh [options]
|
||||
# Options:
|
||||
# --rebuild Force rebuild of Docker images
|
||||
# --stop Stop all services
|
||||
# --restart Restart all services
|
||||
# =============================================================================
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
COMPOSE_FILES="-f docker-compose.yml -f docker-compose.local.yml --env-file .env.local"
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
print_header() {
|
||||
echo -e "${BLUE}==============================================================================${NC}"
|
||||
echo -e "${BLUE}$1${NC}"
|
||||
echo -e "${BLUE}==============================================================================${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}✓ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}✗ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠ $1${NC}"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "${BLUE}ℹ $1${NC}"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Pre-flight Checks
|
||||
# =============================================================================
|
||||
|
||||
preflight_checks() {
|
||||
print_header "Pre-flight Checks"
|
||||
|
||||
# Check if running from correct directory
|
||||
if [ ! -f "docker-compose.yml" ]; then
|
||||
print_error "docker-compose.yml not found. Please run from project root."
|
||||
exit 1
|
||||
fi
|
||||
print_success "Running from correct directory"
|
||||
|
||||
# Check if .env.local exists
|
||||
if [ ! -f ".env.local" ]; then
|
||||
print_error ".env.local not found. Please create it first."
|
||||
print_info "You can copy from .env.prod.example and modify for local settings"
|
||||
exit 1
|
||||
fi
|
||||
print_success ".env.local found"
|
||||
|
||||
# Check if secrets directory exists
|
||||
if [ ! -d "secrets" ]; then
|
||||
print_error "secrets/ directory not found"
|
||||
exit 1
|
||||
fi
|
||||
print_success "secrets/ directory found"
|
||||
|
||||
# Check if GCP credentials exist
|
||||
if [ ! -f "secrets/gcp-credentials.json" ]; then
|
||||
print_error "secrets/gcp-credentials.json not found"
|
||||
exit 1
|
||||
fi
|
||||
print_success "GCP credentials found"
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
print_error "Docker is not running"
|
||||
exit 1
|
||||
fi
|
||||
print_success "Docker is running"
|
||||
|
||||
# Check if docker compose is available
|
||||
if ! docker compose version &> /dev/null; then
|
||||
print_error "docker compose is not installed"
|
||||
exit 1
|
||||
fi
|
||||
print_success "docker compose is available"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Stop Services
|
||||
# =============================================================================
|
||||
|
||||
stop_services() {
|
||||
print_header "Stopping Services"
|
||||
|
||||
print_info "Stopping all containers..."
|
||||
docker compose $COMPOSE_FILES down
|
||||
print_success "Services stopped"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Start Services
|
||||
# =============================================================================
|
||||
|
||||
start_services() {
|
||||
print_header "Starting Local Development Services"
|
||||
|
||||
# Load environment variables
|
||||
export $(cat .env.local | grep -v '^#' | xargs)
|
||||
|
||||
# Build images if needed
|
||||
if [ "$REBUILD" = true ]; then
|
||||
print_info "Building Docker images (--rebuild flag specified)..."
|
||||
docker compose $COMPOSE_FILES build --no-cache
|
||||
print_success "Docker images built"
|
||||
fi
|
||||
|
||||
# Start services
|
||||
print_info "Starting services..."
|
||||
docker compose $COMPOSE_FILES up -d
|
||||
print_success "Services started"
|
||||
|
||||
# Wait for services to be healthy
|
||||
print_info "Waiting for services to be healthy (30 seconds)..."
|
||||
sleep 30
|
||||
|
||||
# Check service health
|
||||
print_info "Checking container status..."
|
||||
docker compose $COMPOSE_FILES ps
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Display Status and Instructions
|
||||
# =============================================================================
|
||||
|
||||
display_status() {
|
||||
print_header "Local Development Environment Ready"
|
||||
|
||||
echo -e "${GREEN}✓ Backend services are running in Docker${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Service URLs:${NC}"
|
||||
echo " API: http://localhost:8003"
|
||||
echo " Docs: http://localhost:8003/docs"
|
||||
echo " MongoDB: mongodb://localhost:27017"
|
||||
echo " Redis: redis://localhost:6379"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next Steps:${NC}"
|
||||
echo " 1. Start the frontend:"
|
||||
echo " ${GREEN}cd frontend && npm run dev${NC}"
|
||||
echo ""
|
||||
echo " 2. Access the application:"
|
||||
echo " ${GREEN}http://localhost:6001/video-accessibility${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Useful Commands:${NC}"
|
||||
echo " View logs: ${GREEN}docker compose logs -f [service]${NC}"
|
||||
echo " Restart service: ${GREEN}docker compose restart [service]${NC}"
|
||||
echo " Stop services: ${GREEN}./scripts/run-local.sh --stop${NC}"
|
||||
echo " Rebuild images: ${GREEN}./scripts/run-local.sh --rebuild${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Available services:${NC} api, worker, mongodb, redis"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Main Function
|
||||
# =============================================================================
|
||||
|
||||
main() {
|
||||
# Parse command line arguments
|
||||
REBUILD=false
|
||||
STOP=false
|
||||
RESTART=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--rebuild)
|
||||
REBUILD=true
|
||||
shift
|
||||
;;
|
||||
--stop)
|
||||
STOP=true
|
||||
shift
|
||||
;;
|
||||
--restart)
|
||||
RESTART=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown option: $1"
|
||||
echo "Usage: $0 [--rebuild] [--stop] [--restart]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
print_header "Accessible Video Platform - Local Development"
|
||||
echo ""
|
||||
|
||||
# Execute based on flags
|
||||
if [ "$STOP" = true ]; then
|
||||
preflight_checks
|
||||
stop_services
|
||||
print_success "Local development environment stopped"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$RESTART" = true ]; then
|
||||
preflight_checks
|
||||
stop_services
|
||||
start_services
|
||||
display_status
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Normal startup
|
||||
preflight_checks
|
||||
start_services
|
||||
display_status
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
13
secrets/gcp-credentials.json
Normal file
13
secrets/gcp-credentials.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"type": "service_account",
|
||||
"project_id": "optical-414516",
|
||||
"private_key_id": "80e2475f641260d5c28e29d10574cef0ba5bff01",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDBPenCel/D+oNr\nf3OZTHsb4GYmqIZpzKLHYsj6/578Oayng0SR8zgAqV1JZSAud3bMFH7tT32Pa6qE\ntB1PNslhgtlYAGa5z9iXDSHksOZ6dAgk2YilZ7deAteGvoeNwkALrxR0FW9Uj0q0\nc1oszSekmpSwzy5QPuQOmt9D1xH+tbX5/zUXxkQmNSKzyPtE/0B5FxdeyoVgK4ZT\nHca6IonDXdW58c9iNdCqboShlb6VZP9zMRMykEuvD9fKMzQUGmjhqI3oGf/B11s9\n+PrtImb9uSrohUVerc/1PjDwA+y/uWet3PGxobFU05GPIbz2sj/nm6Vo1+XDIVgw\nFXSahTdhAgMBAAECggEAC+VTBC6iwcTxXVpVmqF9D25BwfqsRTJC79TcKN3R9haN\nOZKr7SaOOZwzd4n+I5FYtgXc+m1JfkOGfImjjdwCWAcrq6GUSupjAiMQ0kWbKpae\nzOxUErqbxlgucS3X2MyVQyLead1kvE15FjqzpmZkT/Tw8LsQT5uCtoam9kPBgjum\noO0tR6MChkI07LUQ2XXINLLWVbhWLBImksiW9ehcR/htsNMrszSFem6hLe+7PgRq\nxFocz1jt7G/x+csLgyI4cZN1jDv3xd+quxgSgdBZEeOvTWfuTWM+rbMavWzqD2rn\nBpPI1+N1bwNUf0XbKtG6e7WYFUPGGbQjJmLjimAnMQKBgQD7Pdr2fTgek94mvzzb\nnd1Ksri9waf3YJKYchDe5HHtTq+y7IdgFWbmL8ybjKz5TzzHCLt1clg85Fptb14g\noAZxJcS7N1P0uWgHgIWNfm8oFEVmEu2fHfeYjlCPEuroRk6BT9gR7bLwt0mM0mIO\nJJcBbXZyDt4qok/i5r4yeVY2swKBgQDE5tiRjOGq8r5w7q9OMee671g33xw3UcNN\nGlBcbqHnNZZF8+P62ampuHSadsYtOmbQDFbHo7taV7ZhDmtavUU8LQw8TERxj0xQ\nD+p3uCBBQeKPg6h4e2XNjRc6+7riShiCEPwg92M4qpZvlNoGzTogiXiRPBW97Y6z\nacA3Y5oDmwKBgQDodvhF/+DQMiNoGKSX1D6wYiObuDbRJrMdiNVhV2CuoZLibAZq\negMG041vE7/swktLIiJJbm6EkQm2nkgqycaMJNUeIPh2xKKj5mAsZqM1I2R/KN5i\nztiMeInDiE6AcqUq8xTKqfRa1EyilvsRePub34urx2P7cMmX+cZcb3a9DwKBgQCO\nWBxkTKavwMDwP304WFegCnuKGJ77Vv6LdOR3jfs5fMHgXEqKBGTlL1YMfKUT+U5u\nRR1PQgylaReN3rC5bm7o6+AWj0RDnEac8oSce93Fj23MNm/KedrE2KTcnTMjeFFz\nZff/lRiD1L7gd4mOtTq6XudshzVokp5BEchFwpmK1QKBgQC4yrXV4IxIHgCm3mfN\n/rz5iIt6fOGmp07Uv4ZtFcEBQKrWatWMfAAX/lbOGrje9HFNpl5FlYZe/k4ow3O+\ncxXpQOsu9TZdmDJ0YVH6o/+TAPaF/OrMJ8BqrO4J8fJiD0F+y3Ii3pxr9NrH9hjK\n63QAJ9PaA93UVVEbkh98yIOGJA==\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "video-accessibility@optical-414516.iam.gserviceaccount.com",
|
||||
"client_id": "115091905183525974710",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/video-accessibility%40optical-414516.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue