feat: add linguist role and user management navigation

- Add LINGUIST role to UserRole enum (backend + frontend)
- Grant linguists access to QC Review, Final Review, review notes, and VTT editing
- Add MongoDB migration to update schema validator with linguist role
- Add admin seed: vadymsamoilenko@oliver.agency is promoted to admin on startup
- Add User Management sidebar link for admin users
- Fix Login.tsx role type cast to use UserRole instead of hardcoded union

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-16 11:46:33 +01:00
parent c735ba4bb5
commit cf761c4bb6
13 changed files with 238 additions and 30 deletions

View file

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

View file

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

51
backend/app/core/seed.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -67,28 +67,28 @@ function AppContent() {
} />
<Route path="/admin/qc" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['reviewer', 'production', 'admin']}>
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin']}>
<QCList />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/admin/qc/:id" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['reviewer', 'production', 'admin']}>
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin']}>
<QCDetail />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/admin/final" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['reviewer', 'production', 'admin']}>
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin']}>
<FinalList />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/admin/final/:id" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['reviewer', 'production', 'admin']}>
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin']}>
<FinalDetail />
</RoleGate>
</AuthenticatedRoute>

View file

@ -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'],
},
];

View file

@ -84,6 +84,7 @@ export function Dashboard() {
);
case 'reviewer':
case 'linguist':
case 'admin':
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">

View file

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

View file

@ -164,6 +164,7 @@ export function UserDetail() {
>
<option value="client">Client</option>
<option value="reviewer">Reviewer</option>
<option value="linguist">Linguist</option>
<option value="production">Production</option>
<option value="admin">Admin</option>
</select>

View file

@ -120,6 +120,7 @@ export function UserList() {
<option value="admin">Admin</option>
<option value="production">Production</option>
<option value="reviewer">Reviewer</option>
<option value="linguist">Linguist</option>
<option value="client">Client</option>
</select>
</div>
@ -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
>
<option value="client">Client</option>
<option value="reviewer">Reviewer</option>
<option value="linguist">Linguist</option>
<option value="production">Production</option>
<option value="admin">Admin</option>
</select>

View file

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