added MSAL microsoft authentication

This commit is contained in:
michael 2025-10-10 09:19:39 -05:00
parent 0910ade371
commit 665b49c3f1
31 changed files with 4846 additions and 53 deletions

106
.env.local Normal file
View 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)
# =============================================================================

View file

@ -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
View file

@ -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
```

View file

@ -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()
)

View file

@ -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,

View file

@ -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"

View file

@ -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"

View file

@ -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}")

View file

@ -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

View file

@ -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

View 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
View 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]
# =============================================================================

File diff suppressed because it is too large Load diff

View file

@ -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=

View file

@ -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",

View file

@ -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",

View file

@ -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 />

View file

@ -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"

View file

@ -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}`;

View 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] });
},
});
}

View file

@ -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();

View 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');
}

View file

@ -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>,
)

View file

@ -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>

View 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>
);
}

View 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>
);
}

View file

@ -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;
}

View file

@ -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
View 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 "$@"

View 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"
}