diff --git a/backend/app/api/v1/routes_share.py b/backend/app/api/v1/routes_share.py new file mode 100644 index 0000000..8b4eb10 --- /dev/null +++ b/backend/app/api/v1/routes_share.py @@ -0,0 +1,213 @@ +"""Share-token endpoints — create/revoke/list tokens + public read-only view.""" + +import secrets +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from motor.motor_asyncio import AsyncIOMotorDatabase +from pydantic import BaseModel + +from ...core.config import settings +from ...core.database import get_database +from ...core.dependencies import get_current_user, require_roles +from ...models.share_token import ShareToken, ShareTokenResponse +from ...models.user import User, UserRole +from ...services.gcs import get_signed_download_url + +router = APIRouter(tags=["share"]) + +_TOKENS = "share_tokens" +_JOBS = "jobs" + + +def _share_url(token: str) -> str: + base = getattr(settings, "app_url", "https://ai-sandbox.oliver.solutions/video-accessibility") + return f"{base}/share/{token}" + + +# ── Request schemas ─────────────────────────────────────────────────────────── + +class CreateShareTokenRequest(BaseModel): + expires_in_days: Optional[int] = 30 # None = no expiry + label: Optional[str] = None + + +class ShareTokenListResponse(BaseModel): + tokens: list[ShareTokenResponse] + + +class PublicJobPreviewLanguage(BaseModel): + captions_vtt_url: Optional[str] = None + audio_description_vtt_url: Optional[str] = None + accessible_video_mp4_url: Optional[str] = None + audio_description_mp3_url: Optional[str] = None + + +class PublicJobPreviewResponse(BaseModel): + job_id: str + job_title: str + job_status: str + source_language: str + languages: list[str] + language_outputs: dict[str, PublicJobPreviewLanguage] + + +# ── Authenticated routes ────────────────────────────────────────────────────── + +@router.post("/jobs/{job_id}/share", response_model=ShareTokenResponse, status_code=201) +async def create_share_token( + job_id: str, + request: CreateShareTokenRequest, + current_user: User = Depends(require_roles( + UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN, + )), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Generate a read-only share link for a job.""" + job_doc = await db[_JOBS].find_one({"_id": job_id}) + if not job_doc: + raise HTTPException(status_code=404, detail="Job not found") + + token_id = secrets.token_hex(32) + now = datetime.utcnow() + expires_at = (now + timedelta(days=request.expires_in_days)) if request.expires_in_days else None + + token_doc = { + "_id": token_id, + "job_id": job_id, + "organization_id": job_doc.get("organization_id", ""), + "created_by_user_id": str(current_user.id), + "created_by_email": current_user.email, + "created_at": now, + "expires_at": expires_at, + "is_active": True, + "label": request.label, + } + await db[_TOKENS].insert_one(token_doc) + + return ShareTokenResponse( + id=token_id, + job_id=job_id, + created_by_email=current_user.email, + created_at=now, + expires_at=expires_at, + is_active=True, + label=request.label, + share_url=_share_url(token_id), + ) + + +@router.get("/jobs/{job_id}/share", response_model=ShareTokenListResponse) +async def list_share_tokens( + job_id: str, + current_user: User = Depends(require_roles( + UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN, + )), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """List all active share tokens for a job.""" + job_doc = await db[_JOBS].find_one({"_id": job_id}) + if not job_doc: + raise HTTPException(status_code=404, detail="Job not found") + + cursor = db[_TOKENS].find({"job_id": job_id, "is_active": True}) + tokens = [] + async for doc in cursor: + tokens.append(ShareTokenResponse( + id=doc["_id"], + job_id=doc["job_id"], + created_by_email=doc["created_by_email"], + created_at=doc["created_at"], + expires_at=doc.get("expires_at"), + is_active=doc["is_active"], + label=doc.get("label"), + share_url=_share_url(doc["_id"]), + )) + return ShareTokenListResponse(tokens=tokens) + + +@router.delete("/jobs/{job_id}/share/{token_id}", status_code=204) +async def revoke_share_token( + job_id: str, + token_id: str, + current_user: User = Depends(require_roles( + UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN, + )), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Revoke (deactivate) a share token.""" + result = await db[_TOKENS].update_one( + {"_id": token_id, "job_id": job_id}, + {"$set": {"is_active": False}}, + ) + if result.matched_count == 0: + raise HTTPException(status_code=404, detail="Token not found") + + +# ── Public route (no auth) ──────────────────────────────────────────────────── + +@router.get("/public/share/{token}", response_model=PublicJobPreviewResponse) +async def get_public_job_preview( + token: str, + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Return read-only job preview for a valid share token. No authentication required.""" + token_doc = await db[_TOKENS].find_one({"_id": token, "is_active": True}) + if not token_doc: + raise HTTPException(status_code=404, detail="Share link not found or has been revoked") + + if token_doc.get("expires_at") and token_doc["expires_at"] < datetime.utcnow(): + raise HTTPException(status_code=410, detail="Share link has expired") + + job_doc = await db[_JOBS].find_one({"_id": token_doc["job_id"]}) + if not job_doc: + raise HTTPException(status_code=404, detail="Job not found") + + outputs = job_doc.get("outputs") or {} + language_outputs: dict[str, PublicJobPreviewLanguage] = {} + + for lang, lang_output in outputs.items(): + if not isinstance(lang_output, dict): + continue + + lang_data = PublicJobPreviewLanguage() + + if "captions_vtt_gcs" in lang_output: + blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + lang_data.captions_vtt_url = await get_signed_download_url(blob_path, 6) + except Exception: + pass + + if "ad_vtt_gcs" in lang_output: + blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + lang_data.audio_description_vtt_url = await get_signed_download_url(blob_path, 6) + except Exception: + pass + + if "ad_mp3_gcs" in lang_output: + blob_path = lang_output["ad_mp3_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + lang_data.audio_description_mp3_url = await get_signed_download_url(blob_path, 6) + except Exception: + pass + + if "accessible_video_gcs" in lang_output: + blob_path = lang_output["accessible_video_gcs"].replace(f"gs://{settings.gcs_bucket}/", "") + try: + lang_data.accessible_video_mp4_url = await get_signed_download_url(blob_path, 6) + except Exception: + pass + + language_outputs[lang] = lang_data + + return PublicJobPreviewResponse( + job_id=str(job_doc["_id"]), + job_title=job_doc.get("title", "Untitled"), + job_status=job_doc.get("status", ""), + source_language=job_doc.get("source", {}).get("language", "en"), + languages=list(outputs.keys()), + language_outputs=language_outputs, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 9f17d54..b3009ba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,7 @@ from .api.v1.routes_organizations import router as organizations_router from .api.v1.routes_review_notes import router as review_notes_router from .api.v1.routes_tts import router as tts_router from .api.v1.routes_vtt_versions import router as vtt_versions_router +from .api.v1.routes_share import router as share_router from .api.v1.routes_websockets import router as websockets_router from .core.config import settings from .core.database import ( @@ -265,6 +266,7 @@ app.include_router(language_qc_router, prefix="/api/v1") app.include_router(glossaries_router, prefix="/api/v1") app.include_router(tts_router, prefix="/api/v1") app.include_router(admin_router, prefix="/api/v1") +app.include_router(share_router, prefix="/api/v1") app.include_router(websockets_router, prefix="/api/v1") diff --git a/backend/app/models/share_token.py b/backend/app/models/share_token.py new file mode 100644 index 0000000..4375f4f --- /dev/null +++ b/backend/app/models/share_token.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class ShareToken(BaseModel): + id: Optional[str] = None # token itself (32 hex chars), used as _id + job_id: str + organization_id: str + created_by_user_id: str + created_by_email: str + created_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + is_active: bool = True + label: Optional[str] = None # human-readable note e.g. "Sent to ACME 2026-05-01" + + +class ShareTokenResponse(BaseModel): + id: str + job_id: str + created_by_email: str + created_at: datetime + expires_at: Optional[datetime] = None + is_active: bool + label: Optional[str] = None + share_url: str # full public URL, assembled server-side diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 206ba63..b6586f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import { GlossaryDetail } from './routes/admin/glossaries/GlossaryDetail'; import { AuditLog } from './routes/admin/AuditLog'; import { LinguistQueue } from './routes/jobs/LinguistQueue'; import { Downloads } from './routes/Downloads'; +import { ShareView } from './routes/ShareView'; import { AcceptInvite } from './routes/AcceptInvite'; import { NoAccess } from './routes/NoAccess'; import { OrgSettingsLayout } from './routes/org/OrgSettingsLayout'; @@ -59,6 +60,7 @@ function AppContent() {
} /> + } /> } /> diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7a1ed30..dcee5a7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -905,6 +905,42 @@ class ApiClient { async archiveGlossary(clientId: string, glossaryId: string): Promise { await this.client.delete(`/clients/${clientId}/glossaries/${glossaryId}`); } + + // ── Share tokens ──────────────────────────────────────────────────────────── + + async createShareToken(jobId: string, opts?: { expires_in_days?: number; label?: string }): Promise<{ + id: string; job_id: string; created_by_email: string; created_at: string; + expires_at?: string; is_active: boolean; label?: string; share_url: string; + }> { + const r = await this.client.post(`/jobs/${jobId}/share`, opts ?? { expires_in_days: 30 }); + return r.data; + } + + async listShareTokens(jobId: string): Promise<{ tokens: Array<{ + id: string; job_id: string; created_by_email: string; created_at: string; + expires_at?: string; is_active: boolean; label?: string; share_url: string; + }> }> { + const r = await this.client.get(`/jobs/${jobId}/share`); + return r.data; + } + + async revokeShareToken(jobId: string, tokenId: string): Promise { + await this.client.delete(`/jobs/${jobId}/share/${tokenId}`); + } + + async getPublicJobPreview(token: string): Promise<{ + job_id: string; job_title: string; job_status: string; + source_language: string; languages: string[]; + language_outputs: Record; + }> { + const r = await this.client.get(`/public/share/${token}`); + return r.data; + } } export const apiClient = new ApiClient(); diff --git a/frontend/src/routes/ShareView.tsx b/frontend/src/routes/ShareView.tsx new file mode 100644 index 0000000..88bfe89 --- /dev/null +++ b/frontend/src/routes/ShareView.tsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { apiClient } from '../lib/api'; + +type LanguageOutput = { + captions_vtt_url?: string; + audio_description_vtt_url?: string; + accessible_video_mp4_url?: string; + audio_description_mp3_url?: string; +}; + +type JobPreview = { + job_id: string; + job_title: string; + job_status: string; + source_language: string; + languages: string[]; + language_outputs: Record; +}; + +export function ShareView() { + const { token } = useParams<{ token: string }>(); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedLang, setSelectedLang] = useState(''); + + useEffect(() => { + if (!token) return; + apiClient.getPublicJobPreview(token) + .then((data) => { + setPreview(data); + setSelectedLang(data.source_language || data.languages[0] || ''); + }) + .catch((e) => { + const status = e?.response?.status; + if (status === 404) setError('This share link is not valid or has been revoked.'); + else if (status === 410) setError('This share link has expired.'); + else setError('Failed to load preview. Please try again later.'); + }) + .finally(() => setLoading(false)); + }, [token]); + + if (loading) { + return ( +
+
Loading…
+
+ ); + } + + if (error || !preview) { + return ( +
+
+
🔒
+

Link unavailable

+

{error}

+
+
+ ); + } + + const langOut = preview.language_outputs[selectedLang] ?? {}; + + return ( +
+ {/* Header */} +
+
+
+

{preview.job_title}

+

Read-only preview · Shared by Oliver

+
+ {preview.job_status.replace(/_/g, ' ')} +
+
+ +
+ {/* Language selector */} + {preview.languages.length > 1 && ( +
+ Language: +
+ {preview.languages.map((lang) => ( + + ))} +
+
+ )} + + {/* Video player */} + {langOut.accessible_video_mp4_url ? ( +
+
+ ) : ( +
+ No accessible video available for {selectedLang.toUpperCase()} +
+ )} + + {/* Downloads */} +
+

Downloads — {selectedLang.toUpperCase()}

+
+ {langOut.captions_vtt_url && ( + + 📄 + Captions VTT + + )} + {langOut.audio_description_vtt_url && ( + + 📝 + Audio Description VTT + + )} + {langOut.audio_description_mp3_url && ( + + 🔊 + AD Audio MP3 + + )} + {langOut.accessible_video_mp4_url && ( + + 🎬 + Accessible Video MP4 + + )} + {!langOut.captions_vtt_url && !langOut.audio_description_vtt_url && !langOut.accessible_video_mp4_url && ( +

No files available for this language yet.

+ )} +
+
+ +

+ This is a read-only preview link. To request revisions, contact your Oliver project manager. +

+
+
+ ); +} diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index bc5c384..ddcd493 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -222,6 +222,21 @@ export function QCDetail() { onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Bulk assignment failed'), }); + // Share link state + const [showShareModal, setShowShareModal] = useState(false); + const [shareUrl, setShareUrl] = useState(''); + const [shareLabel, setShareLabel] = useState(''); + const [shareCopied, setShareCopied] = useState(false); + + const createShareTokenMutation = useMutation({ + mutationFn: () => apiClient.createShareToken(id!, { expires_in_days: 30, label: shareLabel || undefined }), + onSuccess: (data) => { + setShareUrl(data.share_url); + setShareLabel(''); + }, + onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Failed to generate share link'), + }); + const submitForReviewMutation = useMutation({ mutationFn: (lang: string) => apiClient.submitForReview(id!, lang), onSuccess: () => { @@ -789,12 +804,22 @@ export function QCDetail() {
- +
+ {canAssign && ( + + )} + +
{/* Re-render complete banner */} @@ -1249,6 +1274,72 @@ export function QCDetail() { )} + {/* Share link modal */} + {showShareModal && ( +
+
+

Share read-only link

+

Generate a public link that lets the client preview this job without logging in. Link expires in 30 days.

+ + {!shareUrl ? ( + <> +
+ + setShareLabel(e.target.value)} + className="w-full text-sm border border-gray-300 rounded px-2 py-1.5" + /> +
+
+ + +
+ + ) : ( + <> +
+ e.target.select()} + /> + +
+

Anyone with this link can view the job. Link expires in 30 days.

+
+ +
+ + )} +
+
+ )} + {/* Rendering Status Banner */} {isRendering && (