diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index bdb8ca3..1b5869e 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -246,7 +246,7 @@ async def bulk_delete_jobs( @router.post("/bulk/approve", response_model=BulkApproveResponse) async def bulk_approve_jobs( request: BulkApproveRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Bulk approve multiple jobs with optional method selection for accessible video""" @@ -638,7 +638,7 @@ async def get_job( async def approve_source( job_id: str, request: ApproveSourceRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Approve the source language version (works for any language)""" @@ -720,7 +720,7 @@ async def approve_source( async def approve_english( job_id: str, request: ApproveEnglishRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Legacy endpoint - redirects to approve_source for backwards compatibility""" @@ -736,7 +736,7 @@ async def approve_english( async def reject_job( job_id: str, request: RejectJobRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): result = await db.jobs.find_one_and_update( @@ -783,7 +783,7 @@ async def reject_job( async def complete_job( job_id: str, request: CompleteJobRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): # Get job for validation @@ -860,7 +860,7 @@ async def complete_job( async def reject_final_review( job_id: str, request: RejectJobRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): result = await db.jobs.find_one_and_update( @@ -1171,7 +1171,7 @@ async def get_job_vtt_content( async def update_job_vtt_content( job_id: str, request: VttUpdateRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Update VTT content for a job. If language is not specified, updates source language content.""" @@ -1332,7 +1332,7 @@ async def update_job_vtt_content( async def adjust_vtt_timing( job_id: str, request: VttTimingAdjustRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Adjust timing of VTT content by a specified offset""" @@ -1570,7 +1570,7 @@ async def _delete_job_gcs_assets(job_id: str, job_doc: dict): @router.post("/{job_id}/actions/retry_tts", response_model=JobResponse) async def retry_tts( job_id: str, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Retry TTS generation for a job that failed during TTS synthesis. @@ -1666,7 +1666,7 @@ async def retry_tts( @router.get("/{job_id}/validate", response_model=AssetValidationResponse) async def validate_job_assets( job_id: str, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Validate job assets before completion""" @@ -1695,7 +1695,7 @@ async def validate_job_assets( async def get_accessible_video_edit_state( job_id: str, language: str, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Get current pause points, segment metadata, and TTS regeneration queue for QC editing.""" @@ -1791,7 +1791,7 @@ async def update_pause_point( language: str, cue_index: int, request: PausePointUpdateRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Update a single pause point timing with millisecond precision.""" @@ -1879,7 +1879,7 @@ async def queue_tts_regeneration( job_id: str, language: str, request: TTSRegenerationQueueRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Queue TTS regeneration for specific cues (uses current AD VTT text).""" @@ -1951,7 +1951,7 @@ async def remove_tts_regeneration( job_id: str, language: str, cue_index: int, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Remove a cue from the TTS regeneration queue.""" @@ -2003,7 +2003,7 @@ async def trigger_accessible_video_rerender( job_id: str, language: str, request: RerenderAccessibleVideoRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """ @@ -2101,7 +2101,7 @@ async def trigger_accessible_video_rerender( async def update_tts_preferences( job_id: str, request: UpdateTTSPreferencesRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """ diff --git a/backend/app/api/v1/routes_review_notes.py b/backend/app/api/v1/routes_review_notes.py index d6157db..9618f77 100644 --- a/backend/app/api/v1/routes_review_notes.py +++ b/backend/app/api/v1/routes_review_notes.py @@ -26,7 +26,7 @@ router = APIRouter(prefix="/jobs/{job_id}/review-notes", tags=["review-notes"]) async def list_review_notes( job_id: str, asset_key: Optional[str] = Query(None, description="Filter notes by asset key"), - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """List all review notes for a job, optionally filtered by asset key.""" @@ -57,7 +57,7 @@ async def list_review_notes( async def create_review_note( job_id: str, request: ReviewNoteCreateRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Create a new review note for a video asset.""" @@ -95,7 +95,7 @@ async def create_review_note( async def get_review_note( job_id: str, note_id: str, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Get a single review note by ID.""" @@ -114,7 +114,7 @@ async def update_review_note( job_id: str, note_id: str, request: ReviewNoteUpdateRequest, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Update a review note. Only the note owner can update.""" @@ -150,7 +150,7 @@ async def update_review_note( async def delete_review_note( job_id: str, note_id: str, - current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)), + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)), db: AsyncIOMotorDatabase = Depends(get_database), ): """Delete a review note. Only the note owner can delete.""" diff --git a/backend/app/core/seed.py b/backend/app/core/seed.py new file mode 100644 index 0000000..294daf7 --- /dev/null +++ b/backend/app/core/seed.py @@ -0,0 +1,51 @@ +"""Seed utilities for initial data setup.""" + +import os +import re +from datetime import datetime + +from bson import ObjectId + +from .security import get_password_hash + +DEFAULT_ADMIN_EMAIL = "vadymsamoilenko@oliver.agency" + + +async def seed_default_admin(db) -> None: + """Ensure the default admin user exists and has the admin role. + + Looks up vadymsamoilenko@oliver.agency (case-insensitive). + - If found with a non-admin role: promotes to admin. + - If not found: creates a local-auth admin account. + + Password is read from DEFAULT_ADMIN_PASSWORD env var (fallback: ChangeMe123!). + """ + email_pattern = re.compile(f"^{re.escape(DEFAULT_ADMIN_EMAIL)}$", re.IGNORECASE) + existing = await db.users.find_one({"email": email_pattern}) + + if existing: + if existing.get("role") != "admin": + await db.users.update_one( + {"_id": existing["_id"]}, + {"$set": {"role": "admin", "updated_at": datetime.utcnow()}}, + ) + print(f"✅ Promoted {DEFAULT_ADMIN_EMAIL} to admin role") + else: + print(f"✅ Default admin {DEFAULT_ADMIN_EMAIL} already exists") + return + + password = os.environ.get("DEFAULT_ADMIN_PASSWORD", "ChangeMe123!") + user_doc = { + "_id": str(ObjectId()), + "email": DEFAULT_ADMIN_EMAIL, + "hashed_password": get_password_hash(password), + "full_name": "Vadym Samoilenko", + "role": "admin", + "auth_provider": "local", + "is_active": True, + "created_at": datetime.utcnow(), + "updated_at": datetime.utcnow(), + } + + await db.users.insert_one(user_doc) + print(f"✅ Created default admin: {DEFAULT_ADMIN_EMAIL}") diff --git a/backend/app/main.py b/backend/app/main.py index ce9a929..202bade 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,9 +20,10 @@ from .api.v1.routes_websockets import router as websockets_router from .services.websocket import connection_manager from .core.config import settings from .core.secrets_config import initialize_config -from .core.database import close_mongo_connection, connect_to_mongo, create_indexes +from .core.database import close_mongo_connection, connect_to_mongo, create_indexes, get_database from .core.logging import setup_logging from .core.redis import close_redis_connection, connect_to_redis, get_redis_client +from .core.seed import seed_default_admin from .middleware import create_rate_limit_middleware, create_validation_middleware from .telemetry import ( app_metrics, @@ -74,6 +75,12 @@ async def lifespan(app: FastAPI): await connect_to_mongo() await connect_to_redis() + + try: + db = await get_database() + await seed_default_admin(db) + except Exception as e: + print(f"⚠️ Could not seed default admin: {e}") # await create_indexes() # Temporarily disabled for debugging # Start WebSocket connection manager diff --git a/backend/app/migrations/scripts/migration_2026-04-16-000000_add_linguist_role.py b/backend/app/migrations/scripts/migration_2026-04-16-000000_add_linguist_role.py new file mode 100644 index 0000000..0923081 --- /dev/null +++ b/backend/app/migrations/scripts/migration_2026-04-16-000000_add_linguist_role.py @@ -0,0 +1,137 @@ +"""Add linguist role to user collection schema validator.""" + +from app.migrations.migrator import Migration + + +class Migration(Migration): + """Update MongoDB schema validator to support linguist role.""" + + def __init__(self): + super().__init__() + self.version = "2026-04-16-000000" + self.description = "Add linguist role to user schema validator" + + async def up(self) -> None: + """Update the users collection validator.""" + + validator = { + "$jsonSchema": { + "bsonType": "object", + "required": ["email", "full_name", "role", "auth_provider", "is_active"], + "properties": { + "email": { + "bsonType": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "description": "Must be a valid email address" + }, + "hashed_password": { + "bsonType": ["string", "null"], + "description": "Hashed password (null for Microsoft users)" + }, + "full_name": { + "bsonType": "string", + "minLength": 1, + "description": "User's full name" + }, + "role": { + "enum": ["client", "reviewer", "linguist", "production", "admin"], + "description": "User role" + }, + "auth_provider": { + "enum": ["local", "microsoft"], + "description": "Authentication provider" + }, + "is_active": { + "bsonType": "bool", + "description": "Whether user account is active" + }, + "created_at": { + "bsonType": "date", + "description": "Account creation timestamp" + }, + "updated_at": { + "bsonType": "date", + "description": "Last update timestamp" + } + } + } + } + + try: + await self.db.command({ + "collMod": "users", + "validator": validator, + "validationLevel": "moderate", + "validationAction": "error" + }) + print(f"✅ Updated users collection validator") + except Exception as e: + print(f"⚠️ Could not update validator: {e}") + try: + await self.db.create_collection( + "users", + validator=validator, + validationLevel="moderate", + validationAction="error" + ) + print(f"✅ Created users collection with validator") + except Exception as e2: + print(f"⚠️ Could not create collection: {e2}") + + print(f"✅ Applied migration {self.version}: {self.description}") + + async def down(self) -> None: + """Revert to validator without linguist role.""" + + validator = { + "$jsonSchema": { + "bsonType": "object", + "required": ["email", "full_name", "role", "auth_provider", "is_active"], + "properties": { + "email": { + "bsonType": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "description": "Must be a valid email address" + }, + "hashed_password": { + "bsonType": ["string", "null"], + "description": "Hashed password (null for Microsoft users)" + }, + "full_name": { + "bsonType": "string", + "minLength": 1, + "description": "User's full name" + }, + "role": { + "enum": ["client", "reviewer", "production", "admin"], + "description": "User role" + }, + "auth_provider": { + "enum": ["local", "microsoft"], + "description": "Authentication provider" + }, + "is_active": { + "bsonType": "bool", + "description": "Whether user account is active" + }, + "created_at": { + "bsonType": "date", + "description": "Account creation timestamp" + }, + "updated_at": { + "bsonType": "date", + "description": "Last update timestamp" + } + } + } + } + + await self.db.command({ + "collMod": "users", + "validator": validator, + "validationLevel": "moderate", + "validationAction": "error" + }) + + print(f"⚠️ Rolled back migration {self.version}: {self.description}") + print(f"⚠️ WARNING: Linguist role users will fail validation!") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 7899343..e393b59 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -21,6 +21,7 @@ PyObjectId = Annotated[str, BeforeValidator(validate_object_id)] class UserRole(str, Enum): CLIENT = "client" REVIEWER = "reviewer" + LINGUIST = "linguist" PRODUCTION = "production" ADMIN = "admin" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f1abd73..41326bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -67,28 +67,28 @@ function AppContent() { } /> - + } /> - + } /> - + } /> - + diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index ebec52d..4ff3efc 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -38,13 +38,19 @@ export function Sidebar({ onMobileClose }: SidebarProps) { label: 'QC Review', href: '/admin/qc', icon: '🔍', - roles: ['reviewer', 'production', 'admin'], + roles: ['reviewer', 'linguist', 'production', 'admin'], }, { label: 'Final Review', href: '/admin/final', icon: '✅', - roles: ['reviewer', 'production', 'admin'], + roles: ['reviewer', 'linguist', 'production', 'admin'], + }, + { + label: 'User Management', + href: '/admin/users', + icon: '👥', + roles: ['admin'], }, ]; diff --git a/frontend/src/routes/Dashboard.tsx b/frontend/src/routes/Dashboard.tsx index d6caa31..8db708d 100644 --- a/frontend/src/routes/Dashboard.tsx +++ b/frontend/src/routes/Dashboard.tsx @@ -84,6 +84,7 @@ export function Dashboard() { ); case 'reviewer': + case 'linguist': case 'admin': return (
diff --git a/frontend/src/routes/Login.tsx b/frontend/src/routes/Login.tsx index e668b4d..826d533 100644 --- a/frontend/src/routes/Login.tsx +++ b/frontend/src/routes/Login.tsx @@ -4,6 +4,7 @@ import { useMsal } from '@azure/msal-react'; import { useAuthStore } from '../lib/auth'; import { loginRequest } from '../lib/msalConfig'; import { apiClient } from '../lib/api'; +import type { UserRole } from '../types/api'; export function Login() { const [email, setEmail] = useState(''); @@ -54,7 +55,7 @@ export function Login() { id: loginResponse.user_id, email: loginResponse.email, full_name: loginResponse.full_name, - role: loginResponse.role as 'client' | 'reviewer' | 'admin', + role: loginResponse.role as UserRole, auth_provider: loginResponse.auth_provider, is_active: true, created_at: new Date().toISOString(), diff --git a/frontend/src/routes/admin/UserDetail.tsx b/frontend/src/routes/admin/UserDetail.tsx index 5fc95af..0021703 100644 --- a/frontend/src/routes/admin/UserDetail.tsx +++ b/frontend/src/routes/admin/UserDetail.tsx @@ -164,6 +164,7 @@ export function UserDetail() { > + diff --git a/frontend/src/routes/admin/UserList.tsx b/frontend/src/routes/admin/UserList.tsx index 183a96d..b395553 100644 --- a/frontend/src/routes/admin/UserList.tsx +++ b/frontend/src/routes/admin/UserList.tsx @@ -120,6 +120,7 @@ export function UserList() { +
@@ -204,6 +205,7 @@ export function UserList() { user.role === 'admin' ? 'bg-purple-100 text-purple-800' : user.role === 'production' ? 'bg-orange-100 text-orange-800' : user.role === 'reviewer' ? 'bg-blue-100 text-blue-800' : + user.role === 'linguist' ? 'bg-teal-100 text-teal-800' : 'bg-green-100 text-green-800' }`}> {user.role} @@ -427,6 +429,7 @@ function CreateUserModal({ onClose, onSuccess }: { onClose: () => void; onSucces > + diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index e8d11f2..adfdebf 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -16,7 +16,7 @@ export type JobStatus = | "pending_final_review" | "completed"; -export type UserRole = "client" | "reviewer" | "production" | "admin"; +export type UserRole = "client" | "reviewer" | "linguist" | "production" | "admin"; export type AuthProvider = "local" | "microsoft"; export interface User {