feat(share): add video-level secure share links with password unlock and session-based watch/comment access

This commit is contained in:
Yusuf İpek
2026-02-23 17:11:32 +03:00
parent d15e5192ac
commit 9058317247
16 changed files with 1253 additions and 46 deletions
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
interface ShareLinkBootstrapProps {
videoId: string;
shareToken: string;
}
export function ShareLinkBootstrap({ videoId, shareToken }: ShareLinkBootstrapProps) {
const router = useRouter();
const [error, setError] = useState('');
useEffect(() => {
let isCancelled = false;
async function establishSession() {
try {
const response = await fetch(`/watch/${videoId}/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shareToken }),
});
if (isCancelled) return;
if (response.ok) {
router.replace(`/watch/${videoId}`);
router.refresh();
return;
}
const payload = (await response.json().catch(() => null)) as { requiresPassword?: boolean; error?: string } | null;
if (payload?.requiresPassword) {
router.replace(`/watch/${videoId}?unlock=1`);
return;
}
setError(payload?.error || 'Invalid or expired share link');
} catch {
if (!isCancelled) {
setError('Failed to open share link');
}
}
}
void establishSession();
return () => {
isCancelled = true;
};
}, [router, shareToken, videoId]);
return (
<div className="h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm text-center space-y-3">
<div className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
<h1 className="text-lg font-semibold">Opening shared video</h1>
<p className="text-sm text-muted-foreground">{error || 'Verifying link access...'}</p>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Lock, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface ShareLinkUnlockProps {
videoId: string;
}
export function ShareLinkUnlock({ videoId }: ShareLinkUnlockProps) {
const router = useRouter();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const submitPassword = async () => {
if (!password.trim()) return;
setIsSubmitting(true);
setError('');
try {
const response = await fetch(`/watch/${videoId}/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
setError(payload?.error || 'Invalid password');
return;
}
router.replace(`/watch/${videoId}`);
router.refresh();
} catch {
setError('Failed to verify password');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm">
<div className="text-center mb-5">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 mb-3">
<Lock className="h-6 w-6 text-primary" />
</div>
<h1 className="text-xl font-semibold">Password Required</h1>
<p className="text-sm text-muted-foreground mt-1">Enter the password to continue to the shared video.</p>
</div>
<div className="space-y-3">
<Input
type="password"
placeholder="Password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
void submitPassword();
}
}}
autoFocus
/>
<Button className="w-full" disabled={isSubmitting} onClick={() => void submitPassword()}>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Continue'}
</Button>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<p className="text-xs text-muted-foreground text-center mt-4">
Or <Link href="/login" className="underline hover:text-foreground">sign in</Link> with your account
</p>
</div>
</div>
);
}
+13
View File
@@ -12,6 +12,10 @@ import {
Link as LinkIcon,
AlertCircle,
CheckCircle2,
Share2,
Pencil,
Plus,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -245,16 +249,25 @@ export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/videos/${video.id}/share`}>
<Share2 className="mr-2 h-4 w-4" />
Share
</Link>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShowEditDialog(true)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Version
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onSelect={() => setShowDeleteDialog(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
+16 -3
View File
@@ -40,6 +40,7 @@ import {
Image as ImageIcon,
Download,
FileText,
Share2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -386,6 +387,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}, [mode]);
const isGuest = video ? !video.isAuthenticated : false;
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
const [showVersionDialog, setShowVersionDialog] = useState(false);
const [newVersionUrl, setNewVersionUrl] = useState('');
@@ -505,7 +507,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const apiBasePath = mode === 'dashboard'
? `/api/projects/${propProjectId}/videos/${videoId}`
: `/api/watch/${videoId}`;
: `/api/watch/${videoId}?includeComments=true`;
useEffect(() => {
async function fetchVideo() {
@@ -641,7 +643,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const embedUrl = useMemo(() => {
if (!activeVersion) return '';
if (activeVersion.providerId === 'youtube') {
return `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
const base = `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
if (typeof window === 'undefined') return base;
const origin = window.location.origin;
return `${base}&origin=${encodeURIComponent(origin)}`;
}
if (activeVersion.providerId === 'bunny') {
return `https://${BUNNY_PULL_ZONE_HOSTNAME}/${activeVersion.videoId}/playlist.m3u8`;
@@ -703,6 +708,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}, [isApiLoaded]);
useEffect(() => {
if (!canInitializePlayer) return;
if (!activeProviderId) return;
const isYoutube = activeProviderId === 'youtube';
const isBunny = activeProviderId === 'bunny';
@@ -1041,7 +1047,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
bunnyRetryTimerRef.current = null;
}
};
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId]);
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId, canInitializePlayer]);
// Save detected duration to DB if the version doesn't have one stored
useEffect(() => {
@@ -2884,6 +2890,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
{mode === 'dashboard' && (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
<Share2 className="h-4 w-4 mr-1" />
Share Video
</Link>
</Button>
<div className="hidden sm:flex items-center gap-2">
<Dialog open={showVersionDialog} onOpenChange={setShowVersionDialog}>
<DialogTrigger asChild>
@@ -3138,6 +3150,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
width="100%"
height="100%"
className="absolute inset-0 w-full h-full border-0"
referrerPolicy="origin-when-cross-origin"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>