feat(pm15): share read-only link for client preview

Backend:
- ShareToken model (share_tokens collection)
- POST /jobs/{id}/share — create token (PM/PROD/ADMIN)
- GET /jobs/{id}/share — list active tokens
- DELETE /jobs/{id}/share/{token_id} — revoke token
- GET /public/share/{token} — unauthenticated preview with signed GCS URLs (6h TTL)
  Returns video, captions, AD for all languages

Frontend:
- ShareView.tsx — public page at /share/:token with language switcher, video player, download tiles
- App.tsx — /share/:token route (no auth wrapper)
- QCDetail.tsx — "↗ Share link" button in header → modal to generate + copy link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-29 18:56:44 +01:00
parent f1a9e6ee46
commit abf81515a4
7 changed files with 552 additions and 6 deletions

View file

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

View file

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

View file

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

View file

@ -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() {
<div className="min-h-screen bg-gray-50">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/share/:token" element={<ShareView />} />
<Route path="/accept-invite" element={<AcceptInvite />} />
<Route path="/no-access" element={
<AuthenticatedRoute>

View file

@ -905,6 +905,42 @@ class ApiClient {
async archiveGlossary(clientId: string, glossaryId: string): Promise<void> {
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<void> {
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<string, {
captions_vtt_url?: string;
audio_description_vtt_url?: string;
accessible_video_mp4_url?: string;
audio_description_mp3_url?: string;
}>;
}> {
const r = await this.client.get(`/public/share/${token}`);
return r.data;
}
}
export const apiClient = new ApiClient();

View file

@ -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<string, LanguageOutput>;
};
export function ShareView() {
const { token } = useParams<{ token: string }>();
const [preview, setPreview] = useState<JobPreview | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-500 text-sm">Loading</div>
</div>
);
}
if (error || !preview) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center max-w-md px-4">
<div className="text-4xl mb-4">🔒</div>
<h1 className="text-xl font-semibold text-gray-800 mb-2">Link unavailable</h1>
<p className="text-gray-500 text-sm">{error}</p>
</div>
</div>
);
}
const langOut = preview.language_outputs[selectedLang] ?? {};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-6 py-4">
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900">{preview.job_title}</h1>
<p className="text-sm text-gray-500 mt-0.5">Read-only preview · Shared by Oliver</p>
</div>
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded-full">{preview.job_status.replace(/_/g, ' ')}</span>
</div>
</div>
<div className="max-w-5xl mx-auto px-6 py-8 space-y-6">
{/* Language selector */}
{preview.languages.length > 1 && (
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700">Language:</span>
<div className="flex gap-2 flex-wrap">
{preview.languages.map((lang) => (
<button
key={lang}
onClick={() => setSelectedLang(lang)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
selectedLang === lang
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-600 border-gray-300 hover:border-indigo-400'
}`}
>
{lang.toUpperCase()}
{lang === preview.source_language && <span className="ml-1 opacity-60">(source)</span>}
</button>
))}
</div>
</div>
)}
{/* Video player */}
{langOut.accessible_video_mp4_url ? (
<div className="bg-black rounded-xl overflow-hidden">
<video
key={langOut.accessible_video_mp4_url}
controls
className="w-full max-h-[60vh]"
src={langOut.accessible_video_mp4_url}
/>
</div>
) : (
<div className="bg-gray-100 rounded-xl h-48 flex items-center justify-center text-gray-400 text-sm">
No accessible video available for {selectedLang.toUpperCase()}
</div>
)}
{/* Downloads */}
<div className="bg-white border border-gray-200 rounded-xl p-5">
<h2 className="text-sm font-semibold text-gray-700 mb-4">Downloads {selectedLang.toUpperCase()}</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{langOut.captions_vtt_url && (
<a
href={langOut.captions_vtt_url}
download={`captions-${selectedLang}.vtt`}
className="flex flex-col items-center gap-2 p-3 border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 transition-colors text-center"
>
<span className="text-2xl">📄</span>
<span className="text-xs text-gray-700 font-medium">Captions VTT</span>
</a>
)}
{langOut.audio_description_vtt_url && (
<a
href={langOut.audio_description_vtt_url}
download={`ad-${selectedLang}.vtt`}
className="flex flex-col items-center gap-2 p-3 border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 transition-colors text-center"
>
<span className="text-2xl">📝</span>
<span className="text-xs text-gray-700 font-medium">Audio Description VTT</span>
</a>
)}
{langOut.audio_description_mp3_url && (
<a
href={langOut.audio_description_mp3_url}
download={`ad-${selectedLang}.mp3`}
className="flex flex-col items-center gap-2 p-3 border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 transition-colors text-center"
>
<span className="text-2xl">🔊</span>
<span className="text-xs text-gray-700 font-medium">AD Audio MP3</span>
</a>
)}
{langOut.accessible_video_mp4_url && (
<a
href={langOut.accessible_video_mp4_url}
download={`accessible-video-${selectedLang}.mp4`}
className="flex flex-col items-center gap-2 p-3 border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 transition-colors text-center"
>
<span className="text-2xl">🎬</span>
<span className="text-xs text-gray-700 font-medium">Accessible Video MP4</span>
</a>
)}
{!langOut.captions_vtt_url && !langOut.audio_description_vtt_url && !langOut.accessible_video_mp4_url && (
<p className="col-span-4 text-sm text-gray-400 italic">No files available for this language yet.</p>
)}
</div>
</div>
<p className="text-xs text-gray-400 text-center">
This is a read-only preview link. To request revisions, contact your Oliver project manager.
</p>
</div>
</div>
);
}

View file

@ -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() {
</div>
</div>
<button
onClick={() => navigate('/admin/qc')}
className="text-gray-500 hover:text-gray-700"
>
Back to Queue
</button>
<div className="flex items-center gap-3">
{canAssign && (
<button
onClick={() => { setShareUrl(''); setShareLabel(''); setShareCopied(false); setShowShareModal(true); }}
className="text-xs px-3 py-1.5 bg-white border border-gray-300 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800"
>
Share link
</button>
)}
<button
onClick={() => navigate('/admin/qc')}
className="text-gray-500 hover:text-gray-700"
>
Back to Queue
</button>
</div>
</div>
{/* Re-render complete banner */}
@ -1249,6 +1274,72 @@ export function QCDetail() {
</div>
)}
{/* Share link modal */}
{showShareModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 space-y-4">
<h3 className="text-lg font-medium">Share read-only link</h3>
<p className="text-sm text-gray-500">Generate a public link that lets the client preview this job without logging in. Link expires in 30 days.</p>
{!shareUrl ? (
<>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Label (optional)</label>
<input
type="text"
placeholder="e.g. Sent to ACME 2026-05-01"
value={shareLabel}
onChange={e => setShareLabel(e.target.value)}
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
/>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowShareModal(false)}
className="px-4 py-2 text-sm border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => createShareTokenMutation.mutate()}
disabled={createShareTokenMutation.isPending}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50"
>
{createShareTokenMutation.isPending ? 'Generating…' : 'Generate link'}
</button>
</div>
</>
) : (
<>
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg border border-gray-200">
<input
readOnly
value={shareUrl}
className="flex-1 text-xs text-gray-700 bg-transparent outline-none select-all"
onFocus={e => e.target.select()}
/>
<button
onClick={() => { navigator.clipboard.writeText(shareUrl); setShareCopied(true); setTimeout(() => setShareCopied(false), 2000); }}
className="text-xs px-2 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700 shrink-0"
>
{shareCopied ? 'Copied!' : 'Copy'}
</button>
</div>
<p className="text-xs text-gray-400">Anyone with this link can view the job. Link expires in 30 days.</p>
<div className="flex justify-end">
<button
onClick={() => setShowShareModal(false)}
className="px-4 py-2 text-sm border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
>
Close
</button>
</div>
</>
)}
</div>
</div>
)}
{/* Rendering Status Banner */}
{isRendering && (
<div className="mb-6 p-4 bg-purple-50 border border-purple-200 rounded-md">