feat: Client → Team → Project isolation system with Project Manager role

Backend:
- New UserRole.PROJECT_MANAGER with pm_client_ids[] on User model
- New models: Client (slug-based), Team (member_user_ids[]), Project (client-scoped)
- Job model gains project_id field
- New GET/POST/PATCH/DELETE /clients, /clients/{id}/teams, /clients/{id}/projects,
  /clients/{id}/pm routes (admin-only client CRUD; PM or admin for teams/projects)
- get_accessible_project_ids() helper: staff→all, PM→their clients' projects,
  CLIENT→projects from teams they belong to (with legacy owner fallback)
- list_jobs, get_job, bulk_download, get_vtt_content, delete_job all use new isolation

Frontend:
- UserRole type gains 'project_manager'
- Job, JobCreateRequest gain project_id field
- Client, Team, Project, PMUser types added
- ApiClient: full client/team/project/PM CRUD methods
- useClients hook with all query/mutation hooks
- Admin pages: ClientList + ClientDetail (teams, members, projects, PM assignment)
- NewJob form: client + project picker (shown when clients exist)
- Sidebar: Clients nav item for admin and project_manager roles
- Routes: /admin/clients and /admin/clients/:clientId behind RoleGate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-27 15:07:50 +01:00
parent f7d4624fc7
commit 2b721d182b
18 changed files with 1552 additions and 42 deletions

View file

@ -36,6 +36,12 @@ SENDGRID_API_KEY=...
EMAIL_FROM=support@yourdomain.com
CLIENT_BASE_URL=https://app.yourdomain.com
# AI Cost Tracker
COST_TRACKER_BASE_URL=https://optical-dev.oliver.solutions/cost-tracker/v1
COST_TRACKER_API_KEY=...
COST_TRACKER_SOURCE_APP=video-accessibility
COST_TRACKER_ENABLED=true
# Observability
SENTRY_DSN=...
OTEL_EXPORTER_OTLP_ENDPOINT=...

View file

@ -0,0 +1,435 @@
"""
Client / Team / Project management.
Access rules:
- Client CRUD Admin only
- PM assignment on client Admin only
- Team CRUD + membership Admin or PM of that client
- Project CRUD Admin or PM of that client
- List projects (read) Admin, PM, or any team member of the client
"""
from datetime import datetime, timezone
from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException
from motor.motor_asyncio import AsyncIOMotorDatabase
from pydantic import BaseModel
from ...core.database import get_database
from ...core.dependencies import get_current_user, require_pm_for_client, require_roles
from ...models.client import (
Client,
ClientCreate,
ClientUpdate,
Project,
ProjectCreate,
ProjectUpdate,
Team,
TeamCreate,
TeamUpdate,
)
from ...models.user import User, UserRole
router = APIRouter(prefix="/clients", tags=["clients"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _now() -> datetime:
return datetime.now(timezone.utc)
async def _get_client_or_404(client_id: str, db: AsyncIOMotorDatabase) -> dict:
doc = await db.clients.find_one({"_id": client_id})
if not doc:
raise HTTPException(status_code=404, detail="Client not found")
return doc
async def _get_team_or_404(team_id: str, client_id: str, db: AsyncIOMotorDatabase) -> dict:
doc = await db.teams.find_one({"_id": team_id, "client_id": client_id})
if not doc:
raise HTTPException(status_code=404, detail="Team not found")
return doc
async def _get_project_or_404(project_id: str, client_id: str, db: AsyncIOMotorDatabase) -> dict:
doc = await db.projects.find_one({"_id": project_id, "client_id": client_id})
if not doc:
raise HTTPException(status_code=404, detail="Project not found")
return doc
def _client_from_doc(doc: dict) -> Client:
return Client(**{**doc, "_id": str(doc["_id"])})
def _team_from_doc(doc: dict) -> Team:
return Team(**{**doc, "_id": str(doc["_id"])})
def _project_from_doc(doc: dict) -> Project:
return Project(**{**doc, "_id": str(doc["_id"])})
# ---------------------------------------------------------------------------
# Client endpoints (Admin only)
# ---------------------------------------------------------------------------
@router.get("", response_model=list[Client])
async def list_clients(
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
if current_user.role == UserRole.ADMIN:
docs = await db.clients.find().to_list(None)
elif current_user.role == UserRole.PROJECT_MANAGER:
client_ids = current_user.pm_client_ids or []
docs = await db.clients.find({"_id": {"$in": client_ids}}).to_list(None)
else:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return [_client_from_doc(d) for d in docs]
@router.post("", response_model=Client)
async def create_client(
body: ClientCreate,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
if await db.clients.find_one({"slug": body.slug}):
raise HTTPException(status_code=409, detail="Slug already exists")
now = _now()
client_id = str(ObjectId())
await db.clients.insert_one({
"_id": client_id,
"name": body.name,
"slug": body.slug,
"is_active": True,
"created_at": now,
"updated_at": now,
})
doc = await db.clients.find_one({"_id": client_id})
return _client_from_doc(doc)
@router.get("/{client_id}", response_model=Client)
async def get_client(
client_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
if current_user.role not in (UserRole.ADMIN, UserRole.PROJECT_MANAGER):
raise HTTPException(status_code=403, detail="Insufficient permissions")
if current_user.role == UserRole.PROJECT_MANAGER and client_id not in (current_user.pm_client_ids or []):
raise HTTPException(status_code=403, detail="Not a manager for this client")
doc = await _get_client_or_404(client_id, db)
return _client_from_doc(doc)
@router.patch("/{client_id}", response_model=Client)
async def update_client(
client_id: str,
body: ClientUpdate,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
update: dict = {k: v for k, v in body.model_dump(exclude_none=True).items()}
if not update:
raise HTTPException(status_code=422, detail="No fields to update")
if "slug" in update and await db.clients.find_one({"slug": update["slug"], "_id": {"$ne": client_id}}):
raise HTTPException(status_code=409, detail="Slug already exists")
update["updated_at"] = _now()
await db.clients.update_one({"_id": client_id}, {"$set": update})
doc = await db.clients.find_one({"_id": client_id})
return _client_from_doc(doc)
@router.delete("/{client_id}", status_code=204)
async def deactivate_client(
client_id: str,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await db.clients.update_one({"_id": client_id}, {"$set": {"is_active": False, "updated_at": _now()}})
# ---------------------------------------------------------------------------
# PM assignment (Admin only)
# ---------------------------------------------------------------------------
class AssignPMRequest(BaseModel):
user_id: str
@router.post("/{client_id}/pm", status_code=204)
async def assign_pm(
client_id: str,
body: AssignPMRequest,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
user_doc = await db.users.find_one({"_id": body.user_id})
if not user_doc:
raise HTTPException(status_code=404, detail="User not found")
await db.users.update_one(
{"_id": body.user_id},
{
"$addToSet": {"pm_client_ids": client_id},
"$set": {"role": UserRole.PROJECT_MANAGER.value, "updated_at": _now()},
},
)
@router.delete("/{client_id}/pm/{user_id}", status_code=204)
async def remove_pm(
client_id: str,
user_id: str,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
await db.users.update_one(
{"_id": user_id},
{"$pull": {"pm_client_ids": client_id}, "$set": {"updated_at": _now()}},
)
# Downgrade role if no more PM assignments
user_doc = await db.users.find_one({"_id": user_id})
if user_doc and not user_doc.get("pm_client_ids"):
await db.users.update_one(
{"_id": user_id},
{"$set": {"role": UserRole.CLIENT.value, "updated_at": _now()}},
)
@router.get("/{client_id}/pm", response_model=list[dict])
async def list_pms(
client_id: str,
current_user: User = Depends(require_roles(UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
users = await db.users.find(
{"pm_client_ids": client_id},
{"_id": 1, "email": 1, "full_name": 1, "role": 1},
).to_list(None)
return [{"id": str(u["_id"]), "email": u["email"], "full_name": u["full_name"], "role": u["role"]} for u in users]
# ---------------------------------------------------------------------------
# Team endpoints
# ---------------------------------------------------------------------------
@router.get("/{client_id}/teams", response_model=list[Team])
async def list_teams(
client_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
_assert_client_access(current_user, client_id)
docs = await db.teams.find({"client_id": client_id}).to_list(None)
return [_team_from_doc(d) for d in docs]
@router.post("/{client_id}/teams", response_model=Team)
async def create_team(
client_id: str,
body: TeamCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
_assert_pm_or_admin(current_user, client_id)
now = _now()
team_id = str(ObjectId())
await db.teams.insert_one({
"_id": team_id,
"name": body.name,
"client_id": client_id,
"member_user_ids": [],
"created_at": now,
"updated_at": now,
})
doc = await db.teams.find_one({"_id": team_id})
return _team_from_doc(doc)
@router.patch("/{client_id}/teams/{team_id}", response_model=Team)
async def update_team(
client_id: str,
team_id: str,
body: TeamUpdate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
_assert_pm_or_admin(current_user, client_id)
await _get_team_or_404(team_id, client_id, db)
update = {k: v for k, v in body.model_dump(exclude_none=True).items()}
if not update:
raise HTTPException(status_code=422, detail="No fields to update")
update["updated_at"] = _now()
await db.teams.update_one({"_id": team_id}, {"$set": update})
doc = await db.teams.find_one({"_id": team_id})
return _team_from_doc(doc)
@router.delete("/{client_id}/teams/{team_id}", status_code=204)
async def delete_team(
client_id: str,
team_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
_assert_pm_or_admin(current_user, client_id)
await _get_team_or_404(team_id, client_id, db)
await db.teams.delete_one({"_id": team_id})
# Team membership
class AddMemberRequest(BaseModel):
user_id: str
@router.post("/{client_id}/teams/{team_id}/members", status_code=204)
async def add_team_member(
client_id: str,
team_id: str,
body: AddMemberRequest,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
_assert_pm_or_admin(current_user, client_id)
await _get_team_or_404(team_id, client_id, db)
if not await db.users.find_one({"_id": body.user_id}):
raise HTTPException(status_code=404, detail="User not found")
await db.teams.update_one(
{"_id": team_id},
{"$addToSet": {"member_user_ids": body.user_id}, "$set": {"updated_at": _now()}},
)
@router.delete("/{client_id}/teams/{team_id}/members/{user_id}", status_code=204)
async def remove_team_member(
client_id: str,
team_id: str,
user_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
_assert_pm_or_admin(current_user, client_id)
await _get_team_or_404(team_id, client_id, db)
await db.teams.update_one(
{"_id": team_id},
{"$pull": {"member_user_ids": user_id}, "$set": {"updated_at": _now()}},
)
# ---------------------------------------------------------------------------
# Project endpoints
# ---------------------------------------------------------------------------
@router.get("/{client_id}/projects", response_model=list[Project])
async def list_projects(
client_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
_assert_client_access(current_user, client_id)
docs = await db.projects.find({"client_id": client_id}).to_list(None)
return [_project_from_doc(d) for d in docs]
@router.post("/{client_id}/projects", response_model=Project)
async def create_project(
client_id: str,
body: ProjectCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
_assert_pm_or_admin(current_user, client_id)
now = _now()
project_id = str(ObjectId())
await db.projects.insert_one({
"_id": project_id,
"name": body.name,
"client_id": client_id,
"is_active": True,
"created_at": now,
"updated_at": now,
})
doc = await db.projects.find_one({"_id": project_id})
return _project_from_doc(doc)
@router.patch("/{client_id}/projects/{project_id}", response_model=Project)
async def update_project(
client_id: str,
project_id: str,
body: ProjectUpdate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
_assert_pm_or_admin(current_user, client_id)
await _get_project_or_404(project_id, client_id, db)
update = {k: v for k, v in body.model_dump(exclude_none=True).items()}
if not update:
raise HTTPException(status_code=422, detail="No fields to update")
update["updated_at"] = _now()
await db.projects.update_one({"_id": project_id}, {"$set": update})
doc = await db.projects.find_one({"_id": project_id})
return _project_from_doc(doc)
@router.delete("/{client_id}/projects/{project_id}", status_code=204)
async def archive_project(
client_id: str,
project_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
await _get_client_or_404(client_id, db)
_assert_pm_or_admin(current_user, client_id)
await _get_project_or_404(project_id, client_id, db)
await db.projects.update_one(
{"_id": project_id},
{"$set": {"is_active": False, "updated_at": _now()}},
)
# ---------------------------------------------------------------------------
# Internal access-check helpers
# ---------------------------------------------------------------------------
def _assert_pm_or_admin(user: User, client_id: str) -> None:
if user.role == UserRole.ADMIN:
return
if user.role == UserRole.PROJECT_MANAGER and client_id in (user.pm_client_ids or []):
return
raise HTTPException(status_code=403, detail="Not a manager for this client")
def _assert_client_access(user: User, client_id: str) -> None:
"""Allow admin, PM of the client, or any user (team membership checked at job level)."""
if user.role in (UserRole.ADMIN, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.LINGUIST):
return
if user.role == UserRole.PROJECT_MANAGER and client_id in (user.pm_client_ids or []):
return
# CLIENT role users can list projects so they can pick one on job creation
if user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
return
raise HTTPException(status_code=403, detail="Insufficient permissions")

View file

@ -8,7 +8,7 @@ from motor.motor_asyncio import AsyncIOMotorDatabase
from ...core.config import settings
from ...core.database import get_database
from ...core.dependencies import get_current_user, require_roles
from ...core.dependencies import get_accessible_project_ids, get_current_user, require_roles
from ...core.logging import get_logger
from ...lib.vtt import VTTEditor
from ...models.job import JobStatus, RequestedOutputs
@ -70,6 +70,7 @@ async def create_job(
requested_outputs: str = Form(...), # JSON string
file: UploadFile = File(...),
brand_context: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_database),
):
@ -120,6 +121,7 @@ async def create_job(
}]
},
"brand_context": brand_context or None,
"project_id": project_id or None,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
@ -448,13 +450,15 @@ async def bulk_download_jobs(
detail="No jobs found"
)
# Filter by permissions (clients can only download their own jobs)
# Filter by permissions
_accessible_pids = await get_accessible_project_ids(current_user, db)
valid_jobs = []
for job_doc in jobs:
# Check ownership for client users
if (current_user.role == UserRole.CLIENT and
job_doc["client_id"] != str(current_user.id)):
continue
if _accessible_pids is not None:
_jpid = job_doc.get("project_id")
_own_legacy = job_doc.get("client_id") == str(current_user.id) and not _jpid
if not _own_legacy and not (_jpid and _jpid in _accessible_pids):
continue
# Check downloadable status
if job_doc["status"] not in DOWNLOADABLE_STATUSES:
@ -514,8 +518,18 @@ async def list_jobs(
else:
query["status"] = status
if mine or current_user.role == UserRole.CLIENT:
if mine:
query["client_id"] = str(current_user.id)
else:
project_ids = await get_accessible_project_ids(current_user, db)
if project_ids is not None:
# CLIENT / PM: jobs in accessible projects OR legacy jobs owned by this user
legacy_filter = {"client_id": str(current_user.id), "project_id": None}
if project_ids:
query["$or"] = [{"project_id": {"$in": project_ids}}, legacy_filter]
else:
# No teams / no projects yet — show only own legacy jobs
query.update(legacy_filter)
# Get total count
total = await db.jobs.count_documents(query)
@ -591,12 +605,13 @@ async def get_job(
)
# Check access permissions
if (current_user.role == UserRole.CLIENT and
job_doc["client_id"] != str(current_user.id)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
accessible = await get_accessible_project_ids(current_user, db)
job_project_id = job_doc.get("project_id")
is_own_legacy = job_doc.get("client_id") == str(current_user.id) and not job_project_id
in_project = accessible is None or (job_project_id and job_project_id in (accessible or []))
if not is_own_legacy and not in_project:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
# Check task status if task_id exists
task_id = job_doc.get("task_id")
@ -1035,13 +1050,13 @@ async def get_job_downloads(
detail="Job not found"
)
# Check access permissions (only client or admin/reviewer can download)
if (current_user.role == UserRole.CLIENT and
job_doc["client_id"] != str(current_user.id)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# Check access permissions
if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
_ap = await get_accessible_project_ids(current_user, db)
_jpid = job_doc.get("project_id")
_own = job_doc.get("client_id") == str(current_user.id) and not _jpid
if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
# Allow downloads for jobs that have outputs available
# (PENDING_QC, APPROVED_ENGLISH, TRANSLATING, COMPLETED, etc.)
@ -1163,12 +1178,12 @@ async def get_job_vtt_content(
)
# Check access permissions
if (current_user.role == UserRole.CLIENT and
job_doc["client_id"] != str(current_user.id)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
_ap = await get_accessible_project_ids(current_user, db)
_jpid = job_doc.get("project_id")
_own = job_doc.get("client_id") == str(current_user.id) and not _jpid
if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
# Default to source language if not specified
target_language = language or job_doc["source"].get("language", "en")
@ -1519,13 +1534,13 @@ async def delete_job(
detail="Job not found"
)
# Check permissions: clients can only delete their own jobs, admins/reviewers can delete any
if (current_user.role == UserRole.CLIENT and
job_doc["client_id"] != str(current_user.id)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# Check permissions: clients and PMs can only delete accessible jobs
if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
_ap = await get_accessible_project_ids(current_user, db)
_jpid = job_doc.get("project_id")
_own = job_doc.get("client_id") == str(current_user.id) and not _jpid
if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
logger.info(f"Deleting job {job_id} requested by {current_user.email}")

View file

@ -1,3 +1,5 @@
import asyncio
import time
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
@ -9,6 +11,7 @@ from ...core.logging import get_logger
from ...services.gemini_tts import gemini_tts_service
from ...services.elevenlabs_voices import elevenlabs_voice_service
from ...services.tts import tts_service
from ...services import cost_tracker
from ...core.dependencies import get_current_user
logger = get_logger(__name__)
@ -212,13 +215,15 @@ async def preview_voice(
Generate a voice preview audio sample with all TTS settings applied.
Returns MP3 audio data.
"""
user_id: str = current_user.get("email") or current_user.get("sub") or "unknown"
if request.provider == "elevenlabs":
return await _preview_elevenlabs(request)
return await _preview_elevenlabs(request, user_id)
return await _preview_gemini(request)
return await _preview_gemini(request, user_id)
async def _preview_gemini(request: VoicePreviewRequest) -> Response:
async def _preview_gemini(request: VoicePreviewRequest, user_id: str) -> Response:
"""Generate a Gemini TTS voice preview."""
# Validate voice name
if request.voice_name not in settings.gemini_tts_voices:
@ -240,12 +245,18 @@ async def _preview_gemini(request: VoicePreviewRequest) -> Response:
else:
style_prompt = settings.gemini_tts_style_prompts.get(request.style_preset, "")
sample_text = settings.gemini_tts_preview_samples.get(
request.language,
settings.gemini_tts_preview_samples.get("en", "This is a voice preview.")
)
try:
logger.info(
f"Generating Gemini voice preview: voice={request.voice_name}, language={request.language}, "
f"model={request.model}, speed={request.speed}x, style={request.style_preset}"
)
t0 = time.monotonic()
audio_data = await gemini_tts_service.synthesize_preview(
voice_name=request.voice_name,
language=request.language,
@ -253,6 +264,16 @@ async def _preview_gemini(request: VoicePreviewRequest) -> Response:
speed=request.speed,
style_prompt=style_prompt
)
elapsed_ms = int((time.monotonic() - t0) * 1000)
model_id = settings.gemini_tts_models.get(request.model, settings.gemini_tts_model)
asyncio.create_task(cost_tracker.aio_record(
model=model_id,
provider="google",
user_external_id=user_id,
chars=len(sample_text),
latency_ms=elapsed_ms,
))
return Response(
content=audio_data,
@ -270,7 +291,7 @@ async def _preview_gemini(request: VoicePreviewRequest) -> Response:
) from e
async def _preview_elevenlabs(request: VoicePreviewRequest) -> Response:
async def _preview_elevenlabs(request: VoicePreviewRequest, user_id: str) -> Response:
"""Generate an ElevenLabs TTS voice preview."""
if not tts_service.elevenlabs_available:
raise HTTPException(
@ -293,12 +314,22 @@ async def _preview_elevenlabs(request: VoicePreviewRequest) -> Response:
f"stability={stability}, similarity_boost={similarity_boost}"
)
t0 = time.monotonic()
audio_data = await tts_service._synthesize_text_elevenlabs(
text=sample_text,
voice_id=request.voice_name,
stability=stability,
similarity_boost=similarity_boost,
)
elapsed_ms = int((time.monotonic() - t0) * 1000)
asyncio.create_task(cost_tracker.aio_record(
model="eleven_multilingual_v2",
provider="elevenlabs",
user_external_id=user_id,
chars=len(sample_text),
latency_ms=elapsed_ms,
))
return Response(
content=audio_data,

View file

@ -11,6 +11,9 @@ from .security import decode_token
security = HTTPBearer()
# Roles that see all jobs (no tenant isolation)
STAFF_ROLES = {UserRole.ADMIN, UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION}
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
@ -86,3 +89,60 @@ async def get_current_user_optional(
return User(**user_doc)
except Exception:
return None
async def get_accessible_project_ids(
user: User,
db: AsyncIOMotorDatabase,
) -> Optional[list[str]]:
"""
Returns project IDs the user may access, or None meaning "see everything".
- Staff / Admin None (unrestricted)
- PM all projects in their assigned client(s)
- CLIENT projects belonging to clients of any team the user is in
"""
if user.role in STAFF_ROLES:
return None
if user.role == UserRole.PROJECT_MANAGER:
client_ids = user.pm_client_ids or []
if not client_ids:
return []
projects = await db.projects.find(
{"client_id": {"$in": client_ids}, "is_active": True},
{"_id": 1},
).to_list(None)
return [str(p["_id"]) for p in projects]
# CLIENT role: find teams the user belongs to → their clients → projects
teams = await db.teams.find(
{"member_user_ids": str(user.id)},
{"client_id": 1},
).to_list(None)
client_ids = list({t["client_id"] for t in teams})
if not client_ids:
return []
projects = await db.projects.find(
{"client_id": {"$in": client_ids}, "is_active": True},
{"_id": 1},
).to_list(None)
return [str(p["_id"]) for p in projects]
def require_pm_for_client(client_id_param: str = "client_id"):
"""Dependency: ensures the current user is an Admin or PM for the given client."""
async def checker(
request: Request,
current_user: User = Depends(get_current_user),
) -> User:
if current_user.role == UserRole.ADMIN:
return current_user
if current_user.role != UserRole.PROJECT_MANAGER:
raise HTTPException(status_code=403, detail="Insufficient permissions")
client_id = request.path_params.get(client_id_param)
if client_id not in (current_user.pm_client_ids or []):
raise HTTPException(status_code=403, detail="Not a manager for this client")
return current_user
return checker

View file

@ -12,6 +12,7 @@ from sentry_sdk.integrations.celery import CeleryIntegration
from .api.v1.routes_admin import router as admin_router
from .api.v1.routes_auth import router as auth_router
from .api.v1.routes_clients import router as clients_router
from .api.v1.routes_files import router as files_router
from .api.v1.routes_jobs import router as jobs_router
from .api.v1.routes_review_notes import router as review_notes_router
@ -248,6 +249,7 @@ async def validation_middleware(request, call_next):
# Include routers
app.include_router(auth_router, prefix="/api/v1")
app.include_router(clients_router, prefix="/api/v1")
app.include_router(files_router, prefix="/api/v1")
app.include_router(jobs_router, prefix="/api/v1")
app.include_router(review_notes_router, prefix="/api/v1")

View file

@ -0,0 +1,80 @@
from datetime import datetime
from typing import Optional, Annotated
from bson import ObjectId
from pydantic import BaseModel, Field, BeforeValidator
def validate_object_id(v) -> str:
if isinstance(v, ObjectId):
return str(v)
if isinstance(v, str):
return v
raise ValueError("Invalid ObjectId")
PyObjectId = Annotated[str, BeforeValidator(validate_object_id)]
class Client(BaseModel):
id: Optional[PyObjectId] = Field(None, alias="_id")
name: str
slug: str # lowercase, URL-safe identifier, unique
is_active: bool = True
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
populate_by_name = True
class ClientCreate(BaseModel):
name: str
slug: str
class ClientUpdate(BaseModel):
name: Optional[str] = None
slug: Optional[str] = None
is_active: Optional[bool] = None
class Team(BaseModel):
id: Optional[PyObjectId] = Field(None, alias="_id")
name: str
client_id: str
member_user_ids: list[str] = []
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
populate_by_name = True
class TeamCreate(BaseModel):
name: str
class TeamUpdate(BaseModel):
name: Optional[str] = None
class Project(BaseModel):
id: Optional[PyObjectId] = Field(None, alias="_id")
name: str
client_id: str
is_active: bool = True
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
populate_by_name = True
class ProjectCreate(BaseModel):
name: str
class ProjectUpdate(BaseModel):
name: Optional[str] = None
is_active: Optional[bool] = None

View file

@ -167,6 +167,7 @@ class Job(BaseModel):
ai: Optional[AISection] = None
error: Optional[dict[str, Any]] = None
tts_rewrites: Optional[list[dict[str, Any]]] = None # Track auto-rewritten TTS cues
project_id: Optional[str] = None # Platform project this job belongs to (Client → Project → Job)
brand_context: Optional[str] = None # Brand names present in the video for accurate product identification
cost_tracker_project_id: Optional[str] = None # External project ID for AI cost attribution
created_at: Optional[datetime] = None

View file

@ -23,6 +23,7 @@ class UserRole(str, Enum):
REVIEWER = "reviewer"
LINGUIST = "linguist"
PRODUCTION = "production"
PROJECT_MANAGER = "project_manager"
ADMIN = "admin"
@ -39,6 +40,7 @@ class User(BaseModel):
role: UserRole = UserRole.CLIENT
auth_provider: AuthProvider = AuthProvider.LOCAL
is_active: bool = True
pm_client_ids: list[str] = [] # Client IDs where this user is Project Manager (admin-assigned)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@ -63,3 +65,4 @@ class UserUpdate(BaseModel):
full_name: Optional[str] = None
role: Optional[UserRole] = None
is_active: Optional[bool] = None
pm_client_ids: Optional[list[str]] = None

View file

@ -16,6 +16,8 @@ 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 { ClientList } from './routes/admin/ClientList';
import { ClientDetail } from './routes/admin/ClientDetail';
import { Downloads } from './routes/Downloads';
import { RequireAuth } from './components/Auth/RequireAuth';
import { RoleGate } from './components/Auth/RoleGate';
@ -107,6 +109,20 @@ function AppContent() {
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/admin/clients" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['admin', 'project_manager']}>
<ClientList />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/admin/clients/:clientId" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['admin', 'project_manager']}>
<ClientDetail />
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/downloads/:id" element={
<AuthenticatedRoute>
<Downloads />

View file

@ -52,6 +52,12 @@ export function Sidebar({ onMobileClose }: SidebarProps) {
icon: '👥',
roles: ['admin'],
},
{
label: 'Clients',
href: '/admin/clients',
icon: '🏢',
roles: ['admin', 'project_manager'],
},
];
const filteredItems = sidebarItems.filter(item =>

View file

@ -0,0 +1,154 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/api';
import type { ClientCreateRequest, ProjectCreateRequest, TeamCreateRequest } from '../types/api';
// ── Clients ──────────────────────────────────────────────────────────────────
export function useClients() {
return useQuery({
queryKey: ['clients'],
queryFn: () => apiClient.listClients(),
});
}
export function useClient(clientId: string) {
return useQuery({
queryKey: ['clients', clientId],
queryFn: () => apiClient.getClient(clientId),
enabled: !!clientId,
});
}
export function useCreateClient() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: ClientCreateRequest) => apiClient.createClient(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
});
}
export function useUpdateClient(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<ClientCreateRequest & { is_active: boolean }>) =>
apiClient.updateClient(clientId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['clients'] });
qc.invalidateQueries({ queryKey: ['clients', clientId] });
},
});
}
// ── PMs ──────────────────────────────────────────────────────────────────────
export function usePMs(clientId: string) {
return useQuery({
queryKey: ['clients', clientId, 'pm'],
queryFn: () => apiClient.listPMs(clientId),
enabled: !!clientId,
});
}
export function useAssignPM(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: string) => apiClient.assignPM(clientId, userId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'pm'] }),
});
}
export function useRemovePM(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: string) => apiClient.removePM(clientId, userId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'pm'] }),
});
}
// ── Teams ─────────────────────────────────────────────────────────────────────
export function useTeams(clientId: string) {
return useQuery({
queryKey: ['clients', clientId, 'teams'],
queryFn: () => apiClient.listTeams(clientId),
enabled: !!clientId,
});
}
export function useCreateTeam(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: TeamCreateRequest) => apiClient.createTeam(clientId, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
});
}
export function useUpdateTeam(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ teamId, name }: { teamId: string; name: string }) =>
apiClient.updateTeam(clientId, teamId, name),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
});
}
export function useDeleteTeam(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (teamId: string) => apiClient.deleteTeam(clientId, teamId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
});
}
export function useAddTeamMember(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ teamId, userId }: { teamId: string; userId: string }) =>
apiClient.addTeamMember(clientId, teamId, userId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
});
}
export function useRemoveTeamMember(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ teamId, userId }: { teamId: string; userId: string }) =>
apiClient.removeTeamMember(clientId, teamId, userId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
});
}
// ── Projects ──────────────────────────────────────────────────────────────────
export function useProjects(clientId: string) {
return useQuery({
queryKey: ['clients', clientId, 'projects'],
queryFn: () => apiClient.listProjects(clientId),
enabled: !!clientId,
});
}
export function useCreateProject(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: ProjectCreateRequest) => apiClient.createProject(clientId, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] }),
});
}
export function useUpdateProject(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ projectId, data }: { projectId: string; data: Partial<{ name: string; is_active: boolean }> }) =>
apiClient.updateProject(clientId, projectId, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] }),
});
}
export function useArchiveProject(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (projectId: string) => apiClient.archiveProject(clientId, projectId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] }),
});
}

View file

@ -13,6 +13,7 @@ export interface FileListItem {
export interface SharedJobSettings {
requestedOutputs: RequestedOutputs;
brandContext?: string;
projectId?: string;
}
interface UseMultiUploadOptions {
@ -108,6 +109,7 @@ export function useMultiUpload(options: UseMultiUploadOptions = {}): UseMultiUpl
title: item.autoTitle,
requested_outputs: settings.requestedOutputs,
brand_context: settings.brandContext,
project_id: settings.projectId,
},
item.file,
(progressEvent) => {

View file

@ -38,6 +38,13 @@ import type {
ReviewNotesListResponse,
AccessibleVideoEditState,
PausePointData,
Client,
Team,
Project,
PMUser,
ClientCreateRequest,
TeamCreateRequest,
ProjectCreateRequest,
} from '../types/api';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
@ -162,6 +169,9 @@ class ApiClient {
if (data.brand_context) {
formData.append('brand_context', data.brand_context);
}
if (data.project_id) {
formData.append('project_id', data.project_id);
}
formData.append('file', file);
const response = await this.client.post('/jobs', formData, {
@ -432,9 +442,14 @@ class ApiClient {
}
// Accessible Video QC Editing endpoints
async getAccessibleVideoEditState(jobId: string, language: string): Promise<AccessibleVideoEditState> {
const response = await this.client.get(`/jobs/${jobId}/accessible-video/${language}/edit-state`);
return response.data;
async getAccessibleVideoEditState(jobId: string, language: string): Promise<AccessibleVideoEditState | null> {
try {
const response = await this.client.get(`/jobs/${jobId}/accessible-video/${language}/edit-state`);
return response.data;
} catch (err: any) {
if (err?.response?.status === 404) return null;
throw err;
}
}
async updatePausePoint(
@ -484,6 +499,87 @@ class ApiClient {
);
return response.data;
}
// ── Client / Team / Project ──────────────────────────────────────────────
async listClients(): Promise<Client[]> {
const r = await this.client.get('/clients');
return r.data;
}
async createClient(data: ClientCreateRequest): Promise<Client> {
const r = await this.client.post('/clients', data);
return r.data;
}
async updateClient(clientId: string, data: Partial<ClientCreateRequest & { is_active: boolean }>): Promise<Client> {
const r = await this.client.patch(`/clients/${clientId}`, data);
return r.data;
}
async getClient(clientId: string): Promise<Client> {
const r = await this.client.get(`/clients/${clientId}`);
return r.data;
}
async listPMs(clientId: string): Promise<PMUser[]> {
const r = await this.client.get(`/clients/${clientId}/pm`);
return r.data;
}
async assignPM(clientId: string, userId: string): Promise<void> {
await this.client.post(`/clients/${clientId}/pm`, { user_id: userId });
}
async removePM(clientId: string, userId: string): Promise<void> {
await this.client.delete(`/clients/${clientId}/pm/${userId}`);
}
async listTeams(clientId: string): Promise<Team[]> {
const r = await this.client.get(`/clients/${clientId}/teams`);
return r.data;
}
async createTeam(clientId: string, data: TeamCreateRequest): Promise<Team> {
const r = await this.client.post(`/clients/${clientId}/teams`, data);
return r.data;
}
async updateTeam(clientId: string, teamId: string, name: string): Promise<Team> {
const r = await this.client.patch(`/clients/${clientId}/teams/${teamId}`, { name });
return r.data;
}
async deleteTeam(clientId: string, teamId: string): Promise<void> {
await this.client.delete(`/clients/${clientId}/teams/${teamId}`);
}
async addTeamMember(clientId: string, teamId: string, userId: string): Promise<void> {
await this.client.post(`/clients/${clientId}/teams/${teamId}/members`, { user_id: userId });
}
async removeTeamMember(clientId: string, teamId: string, userId: string): Promise<void> {
await this.client.delete(`/clients/${clientId}/teams/${teamId}/members/${userId}`);
}
async listProjects(clientId: string): Promise<Project[]> {
const r = await this.client.get(`/clients/${clientId}/projects`);
return r.data;
}
async createProject(clientId: string, data: ProjectCreateRequest): Promise<Project> {
const r = await this.client.post(`/clients/${clientId}/projects`, data);
return r.data;
}
async updateProject(clientId: string, projectId: string, data: Partial<{ name: string; is_active: boolean }>): Promise<Project> {
const r = await this.client.patch(`/clients/${clientId}/projects/${projectId}`, data);
return r.data;
}
async archiveProject(clientId: string, projectId: string): Promise<void> {
await this.client.delete(`/clients/${clientId}/projects/${projectId}`);
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,369 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import {
useClient,
useTeams, useCreateTeam, useUpdateTeam, useDeleteTeam,
useAddTeamMember, useRemoveTeamMember,
useProjects, useCreateProject, useUpdateProject, useArchiveProject,
usePMs, useAssignPM, useRemovePM,
} from '../../hooks/useClients';
import { useUsers } from '../../hooks/useUsers';
import { useAuthStore } from '../../stores/authStore';
import { useToastContext } from '../../contexts/ToastContext';
import type { Team, Project } from '../../types/api';
export function ClientDetail() {
const { clientId } = useParams<{ clientId: string }>();
const { user } = useAuthStore();
const toast = useToastContext();
const isAdmin = user?.role === 'admin';
const { data: client, isLoading: clientLoading } = useClient(clientId!);
const { data: teams = [] } = useTeams(clientId!);
const { data: projects = [] } = useProjects(clientId!);
const { data: pms = [] } = usePMs(clientId!);
const { data: usersResp } = useUsers({ size: 200, active_only: true });
const allUsers = usersResp?.users ?? [];
const createTeam = useCreateTeam(clientId!);
const updateTeam = useUpdateTeam(clientId!);
const deleteTeam = useDeleteTeam(clientId!);
const addMember = useAddTeamMember(clientId!);
const removeMember = useRemoveTeamMember(clientId!);
const createProject = useCreateProject(clientId!);
const updateProject = useUpdateProject(clientId!);
const archiveProject = useArchiveProject(clientId!);
const assignPM = useAssignPM(clientId!);
const removePM = useRemovePM(clientId!);
// UI state
const [newTeamName, setNewTeamName] = useState('');
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
const [expandedTeam, setExpandedTeam] = useState<string | null>(null);
const [addMemberTeamId, setAddMemberTeamId] = useState<string | null>(null);
const [selectedMemberUserId, setSelectedMemberUserId] = useState('');
const [newProjectName, setNewProjectName] = useState('');
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [pmUserId, setPmUserId] = useState('');
if (clientLoading) {
return <div className="container mx-auto px-4 py-8 animate-pulse"><div className="h-8 bg-gray-200 rounded w-1/3" /></div>;
}
if (!client) {
return <div className="container mx-auto px-4 py-8 text-red-500">Client not found</div>;
}
const pmUserIds = new Set(pms.map(p => p.id));
const handleCreateTeam = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTeamName.trim()) return;
try {
await createTeam.mutateAsync({ name: newTeamName.trim() });
setNewTeamName('');
toast.success('Team created');
} catch { toast.error('Failed to create team'); }
};
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault();
if (!newProjectName.trim()) return;
try {
await createProject.mutateAsync({ name: newProjectName.trim() });
setNewProjectName('');
toast.success('Project created');
} catch { toast.error('Failed to create project'); }
};
const handleAssignPM = async (e: React.FormEvent) => {
e.preventDefault();
if (!pmUserId) return;
try {
await assignPM.mutateAsync(pmUserId);
setPmUserId('');
toast.success('PM assigned');
} catch { toast.error('Failed to assign PM'); }
};
return (
<div className="container mx-auto px-4 py-8 max-w-5xl space-y-8">
{/* Header */}
<div>
<p className="text-sm text-gray-400 mb-1"> <a href="/admin/clients" className="hover:text-blue-600">Clients</a></p>
<h1 className="text-2xl font-bold text-gray-900">{client.name}</h1>
<p className="text-xs text-gray-400 font-mono mt-0.5">{client.slug}</p>
</div>
{/* Project Managers (admin only) */}
{isAdmin && (
<section className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">Project Managers</h2>
<div className="space-y-2 mb-4">
{pms.length === 0 && <p className="text-sm text-gray-400">No PMs assigned</p>}
{pms.map(pm => (
<div key={pm.id} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
<div>
<p className="text-sm font-medium text-gray-800">{pm.full_name}</p>
<p className="text-xs text-gray-400">{pm.email}</p>
</div>
<button
onClick={async () => {
await removePM.mutateAsync(pm.id);
toast.success('PM removed');
}}
className="text-xs text-red-500 hover:text-red-700"
>
Remove
</button>
</div>
))}
</div>
<form onSubmit={handleAssignPM} className="flex gap-2">
<select
value={pmUserId}
onChange={e => setPmUserId(e.target.value)}
className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select user to assign as PM</option>
{allUsers.filter(u => !pmUserIds.has(u.id)).map(u => (
<option key={u.id} value={u.id}>{u.full_name} ({u.email})</option>
))}
</select>
<button
type="submit"
disabled={!pmUserId || assignPM.isPending}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Assign
</button>
</form>
</section>
)}
{/* Teams */}
<section className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">Teams</h2>
<div className="space-y-3 mb-4">
{teams.length === 0 && <p className="text-sm text-gray-400">No teams yet</p>}
{teams.map(team => (
<div key={team.id} className="border border-gray-200 rounded-lg overflow-hidden">
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-50"
onClick={() => setExpandedTeam(expandedTeam === team.id ? null : team.id)}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-800">{team.name}</span>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
{team.member_user_ids.length} members
</span>
</div>
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
<button
onClick={() => setEditingTeam(team)}
className="text-xs text-gray-500 hover:text-blue-600"
>
Rename
</button>
<button
onClick={async () => {
if (!confirm(`Delete team "${team.name}"?`)) return;
await deleteTeam.mutateAsync(team.id);
toast.success('Team deleted');
}}
className="text-xs text-red-500 hover:text-red-700"
>
Delete
</button>
<span className="text-gray-400">{expandedTeam === team.id ? '▲' : '▼'}</span>
</div>
</div>
{expandedTeam === team.id && (
<div className="border-t border-gray-100 p-3 bg-gray-50">
<div className="space-y-1.5 mb-3">
{team.member_user_ids.length === 0 && (
<p className="text-xs text-gray-400">No members</p>
)}
{team.member_user_ids.map(uid => {
const u = allUsers.find(u => u.id === uid);
return (
<div key={uid} className="flex items-center justify-between text-sm">
<span className="text-gray-700">{u ? `${u.full_name} (${u.email})` : uid}</span>
<button
onClick={async () => {
await removeMember.mutateAsync({ teamId: team.id, userId: uid });
toast.success('Member removed');
}}
className="text-xs text-red-500 hover:text-red-700"
>
×
</button>
</div>
);
})}
</div>
{addMemberTeamId === team.id ? (
<div className="flex gap-2">
<select
value={selectedMemberUserId}
onChange={e => setSelectedMemberUserId(e.target.value)}
className="flex-1 border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Select user</option>
{allUsers.filter(u => !team.member_user_ids.includes(u.id)).map(u => (
<option key={u.id} value={u.id}>{u.full_name} ({u.email})</option>
))}
</select>
<button
disabled={!selectedMemberUserId}
onClick={async () => {
await addMember.mutateAsync({ teamId: team.id, userId: selectedMemberUserId });
setSelectedMemberUserId('');
setAddMemberTeamId(null);
toast.success('Member added');
}}
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
<button onClick={() => setAddMemberTeamId(null)} className="text-xs text-gray-500">Cancel</button>
</div>
) : (
<button
onClick={() => { setAddMemberTeamId(team.id); setSelectedMemberUserId(''); }}
className="text-xs text-blue-600 hover:text-blue-800"
>
+ Add member
</button>
)}
</div>
)}
</div>
))}
</div>
<form onSubmit={handleCreateTeam} className="flex gap-2">
<input
type="text"
value={newTeamName}
onChange={e => setNewTeamName(e.target.value)}
placeholder="New team name…"
className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={!newTeamName.trim() || createTeam.isPending}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
+ Team
</button>
</form>
</section>
{/* Projects */}
<section className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-base font-semibold text-gray-800 mb-4">Projects</h2>
<div className="space-y-2 mb-4">
{projects.length === 0 && <p className="text-sm text-gray-400">No projects yet</p>}
{projects.map(project => (
<div key={project.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
{editingProject?.id === project.id ? (
<form
className="flex gap-2 flex-1"
onSubmit={async e => {
e.preventDefault();
await updateProject.mutateAsync({ projectId: project.id, data: { name: editingProject.name } });
setEditingProject(null);
toast.success('Project updated');
}}
>
<input
type="text"
value={editingProject.name}
onChange={e => setEditingProject({ ...editingProject, name: e.target.value })}
className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
autoFocus
/>
<button type="submit" className="text-xs text-blue-600">Save</button>
<button type="button" onClick={() => setEditingProject(null)} className="text-xs text-gray-500">Cancel</button>
</form>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-800">{project.name}</span>
{!project.is_active && (
<span className="text-xs bg-gray-200 text-gray-500 px-2 py-0.5 rounded-full">Archived</span>
)}
</div>
<div className="flex items-center gap-3">
<button onClick={() => setEditingProject(project)} className="text-xs text-gray-500 hover:text-blue-600">
Rename
</button>
{project.is_active && (
<button
onClick={async () => {
if (!confirm(`Archive project "${project.name}"?`)) return;
await archiveProject.mutateAsync(project.id);
toast.success('Project archived');
}}
className="text-xs text-red-500 hover:text-red-700"
>
Archive
</button>
)}
</div>
</>
)}
</div>
))}
</div>
<form onSubmit={handleCreateProject} className="flex gap-2">
<input
type="text"
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
placeholder="New project name…"
className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={!newProjectName.trim() || createProject.isPending}
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
+ Project
</button>
</form>
</section>
{/* Rename team modal */}
{editingTeam && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form
className="bg-white rounded-xl p-6 w-full max-w-sm shadow-xl"
onSubmit={async e => {
e.preventDefault();
await updateTeam.mutateAsync({ teamId: editingTeam.id, name: editingTeam.name });
setEditingTeam(null);
toast.success('Team renamed');
}}
>
<h3 className="text-base font-semibold mb-4">Rename Team</h3>
<input
type="text"
value={editingTeam.name}
onChange={e => setEditingTeam({ ...editingTeam, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
<div className="flex gap-3">
<button type="button" onClick={() => setEditingTeam(null)} className="flex-1 border border-gray-300 text-sm rounded-lg px-4 py-2 hover:bg-gray-50">Cancel</button>
<button type="submit" className="flex-1 bg-blue-600 text-white text-sm rounded-lg px-4 py-2 hover:bg-blue-700">Save</button>
</div>
</form>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,138 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useClients, useCreateClient } from '../../hooks/useClients';
import { useToastContext } from '../../contexts/ToastContext';
export function ClientList() {
const { data: clients, isLoading } = useClients();
const createClient = useCreateClient();
const toast = useToastContext();
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createClient.mutateAsync({ name, slug });
toast.success('Client created');
setShowCreate(false);
setName('');
setSlug('');
} catch {
toast.error('Failed to create client');
}
};
const autoSlug = (v: string) =>
v.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-4">
{[...Array(4)].map((_, i) => <div key={i} className="h-14 bg-gray-200 rounded" />)}
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Clients</h1>
<p className="text-sm text-gray-500 mt-1">Manage client organisations, their teams and projects</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
>
+ New Client
</button>
</div>
{clients && clients.length === 0 && (
<div className="text-center py-16 bg-gray-50 rounded-xl border border-dashed border-gray-300">
<p className="text-gray-500 text-sm">No clients yet. Create your first client to get started.</p>
</div>
)}
<div className="space-y-3">
{clients?.map(client => (
<Link
key={client.id}
to={`/admin/clients/${client.id}`}
className="flex items-center justify-between p-4 bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-sm transition-all"
>
<div>
<p className="font-medium text-gray-900">{client.name}</p>
<p className="text-xs text-gray-400 mt-0.5 font-mono">{client.slug}</p>
</div>
<div className="flex items-center gap-3">
{!client.is_active && (
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded-full">Inactive</span>
)}
<span className="text-gray-400 text-sm"></span>
</div>
</Link>
))}
</div>
{/* Create modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
<h2 className="text-lg font-semibold mb-4">New Client</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => {
setName(e.target.value);
setSlug(autoSlug(e.target.value));
}}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g. Ford Motor Company"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
<input
type="text"
value={slug}
onChange={e => setSlug(e.target.value)}
required
pattern="[a-z0-9\-]+"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="ford-motor-company"
/>
<p className="text-xs text-gray-400 mt-1">Lowercase letters, numbers and hyphens only</p>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setShowCreate(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-sm rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={createClient.isPending}
className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createClient.isPending ? 'Creating…' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View file

@ -12,6 +12,7 @@ import { useCreateJob } from '../../hooks/useJob';
import { useMultiUpload } from '../../hooks/useMultiUpload';
import { useToastContext } from '../../contexts/ToastContext';
import { generateTitleFromFilename } from '../../lib/fileUtils';
import { useClients, useProjects } from '../../hooks/useClients';
import type { JobCreateRequest, TTSPreferences, AccessibleVideoMethod } from '../../types/api';
const jobSchema = z.object({
@ -38,6 +39,10 @@ export function NewJob() {
// Shared state
const [brandContext, setBrandContext] = useState('');
const [selectedClientId, setSelectedClientId] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState('');
const { data: clients = [] } = useClients();
const { data: projects = [] } = useProjects(selectedClientId);
const [showVoiceSettings, setShowVoiceSettings] = useState(false);
const [ttsPreferences, setTtsPreferences] = useState<TTSPreferences>({
provider: 'gemini',
@ -136,6 +141,7 @@ export function NewJob() {
translation_mode: data.translation_mode,
},
brand_context: brandContext.trim() || undefined,
project_id: selectedProjectId || undefined,
};
try {
@ -216,6 +222,7 @@ export function NewJob() {
translation_mode: data.translation_mode,
},
brandContext: brandContext.trim() || undefined,
projectId: selectedProjectId || undefined,
});
};
@ -262,6 +269,7 @@ export function NewJob() {
translation_mode: data.translation_mode,
},
brandContext: brandContext.trim() || undefined,
projectId: selectedProjectId || undefined,
});
};
@ -690,6 +698,42 @@ export function NewJob() {
</div>
)}
{/* Project */}
{clients.length > 0 && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Client <span className="text-gray-400 font-normal">(optional)</span>
</label>
<select
value={selectedClientId}
onChange={e => { setSelectedClientId(e.target.value); setSelectedProjectId(''); }}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isUploading}
>
<option value=""> No client </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
{selectedClientId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Project <span className="text-gray-400 font-normal">(optional)</span>
</label>
<select
value={selectedProjectId}
onChange={e => setSelectedProjectId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isUploading}
>
<option value=""> No project </option>
{projects.filter(p => p.is_active).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
)}
</div>
)}
{/* Brand Context */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">

View file

@ -16,7 +16,7 @@ export type JobStatus =
| "pending_final_review"
| "completed";
export type UserRole = "client" | "reviewer" | "linguist" | "production" | "admin";
export type UserRole = "client" | "reviewer" | "linguist" | "production" | "project_manager" | "admin";
export type AuthProvider = "local" | "microsoft";
export interface User {
@ -202,6 +202,7 @@ export interface Job {
ai?: AISection;
error?: Record<string, unknown>;
tts_rewrites?: TTSRewriteItem[];
project_id?: string;
cost_tracker_project_id?: string;
created_at: string;
updated_at: string;
@ -246,7 +247,8 @@ export interface MicrosoftLoginResponse {
export interface JobCreateRequest {
title: string;
requested_outputs: RequestedOutputs;
brand_context?: string; // Comma-separated brand names present in the video
brand_context?: string;
project_id?: string;
}
export interface JobListResponse {
@ -342,6 +344,56 @@ export interface UpdateUserRequest {
full_name?: string;
role?: UserRole;
is_active?: boolean;
pm_client_ids?: string[];
}
// Client / Team / Project types
export interface Client {
id: string;
name: string;
slug: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface Team {
id: string;
name: string;
client_id: string;
member_user_ids: string[];
created_at: string;
updated_at: string;
}
export interface Project {
id: string;
name: string;
client_id: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface PMUser {
id: string;
email: string;
full_name: string;
role: UserRole;
}
export interface ClientCreateRequest {
name: string;
slug: string;
}
export interface TeamCreateRequest {
name: string;
}
export interface ProjectCreateRequest {
name: string;
}
export interface ResetPasswordResponse {