From 23f0ceca3f4c89267f1498b162ac84e7bc432f84 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:37:57 +0200 Subject: [PATCH] client passwords --- app/api/client/[token]/approve/route.ts | 16 +-- app/api/client/[token]/auth/route.ts | 48 +++++++ app/api/client/[token]/comment/route.ts | 16 +-- app/api/client/[token]/project/route.ts | 16 +-- .../[token]/versions/[versionId]/route.ts | 16 +-- app/api/review-sessions/route.ts | 9 +- app/client/[token]/page.tsx | 52 ++++++-- .../[token]/review/[versionId]/page.tsx | 54 ++++++-- components/clients/ReviewPasswordGate.tsx | 122 ++++++++++++++++++ components/clients/ShareReviewDialog.tsx | 47 ++++++- lib/review-auth.ts | 34 +++++ .../migration.sql | 2 + prisma/schema.prisma | 19 +-- 13 files changed, 377 insertions(+), 74 deletions(-) create mode 100644 app/api/client/[token]/auth/route.ts create mode 100644 components/clients/ReviewPasswordGate.tsx create mode 100644 lib/review-auth.ts create mode 100644 prisma/migrations/20260612000000_add_review_session_password/migration.sql diff --git a/app/api/client/[token]/approve/route.ts b/app/api/client/[token]/approve/route.ts index 2d8b0cd..6af9ce9 100644 --- a/app/api/client/[token]/approve/route.ts +++ b/app/api/client/[token]/approve/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { ApprovalStatus } from "@prisma/client"; import { recalcShotStatus } from "@/lib/shot-status"; +import { validateReviewToken } from "@/lib/review-auth"; async function getOrCreateClientUser(email: string, label?: string | null) { const existing = await db.user.findUnique({ where: { email } }); @@ -16,22 +17,19 @@ async function getOrCreateClientUser(email: string, label?: string | null) { }); } -async function validateToken(token: string) { - const session = await db.reviewSession.findUnique({ where: { token } }); - if (!session || !session.isActive) return null; - if (session.expiresAt && session.expiresAt < new Date()) return null; - return session; -} - export async function POST( req: NextRequest, { params }: { params: Promise<{ token: string }> } ) { const { token } = await params; - const session = await validateToken(token); - if (!session) { + const result = await validateReviewToken(token, req); + if (result.type === "requiresPassword") { + return NextResponse.json({ requiresPassword: true }, { status: 401 }); + } + if (result.type === "invalid") { return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 }); } + const session = result.session; const body = await req.json(); const { versionId, shotId, action, status, notes } = body; diff --git a/app/api/client/[token]/auth/route.ts b/app/api/client/[token]/auth/route.ts new file mode 100644 index 0000000..2e1cf5c --- /dev/null +++ b/app/api/client/[token]/auth/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import bcrypt from "bcryptjs"; +import { makeUnlockCookieValue } from "@/lib/review-auth"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params; + + const body = await req.json().catch(() => ({})); + const { password } = body as { password?: string }; + + if (!password || typeof password !== "string") { + return NextResponse.json({ error: "Password required" }, { status: 400 }); + } + + const session = await db.reviewSession.findUnique({ where: { token } }); + if (!session || !session.isActive) { + return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 }); + } + if (session.expiresAt && session.expiresAt < new Date()) { + return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 }); + } + + if (!session.passwordHash) { + return NextResponse.json({ success: true }); + } + + const valid = await bcrypt.compare(password, session.passwordHash); + if (!valid) { + return NextResponse.json({ error: "Incorrect password" }, { status: 401 }); + } + + const cookieName = `rsauth_${token}`; + const cookieValue = makeUnlockCookieValue(token); + + const res = NextResponse.json({ success: true }); + res.cookies.set(cookieName, cookieValue, { + httpOnly: true, + sameSite: "lax", + path: "/", + expires: session.expiresAt, + secure: process.env.NODE_ENV === "production", + }); + return res; +} diff --git a/app/api/client/[token]/comment/route.ts b/app/api/client/[token]/comment/route.ts index 3049e9f..ccbe620 100644 --- a/app/api/client/[token]/comment/route.ts +++ b/app/api/client/[token]/comment/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { slackNotifyNewFeedback } from "@/lib/slack"; +import { validateReviewToken } from "@/lib/review-auth"; /** Find or create a guest user for the client reviewer based on the session email */ async function getOrCreateClientUser(email: string, label?: string | null) { @@ -16,22 +17,19 @@ async function getOrCreateClientUser(email: string, label?: string | null) { }); } -async function validateToken(token: string) { - const session = await db.reviewSession.findUnique({ where: { token } }); - if (!session || !session.isActive) return null; - if (session.expiresAt && session.expiresAt < new Date()) return null; - return session; -} - export async function POST( req: NextRequest, { params }: { params: Promise<{ token: string }> } ) { const { token } = await params; - const session = await validateToken(token); - if (!session) { + const result = await validateReviewToken(token, req); + if (result.type === "requiresPassword") { + return NextResponse.json({ requiresPassword: true }, { status: 401 }); + } + if (result.type === "invalid") { return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 }); } + const session = result.session; const body = await req.json(); const { versionId, frameNumber, timestamp, text } = body; diff --git a/app/api/client/[token]/project/route.ts b/app/api/client/[token]/project/route.ts index 3b20f8f..f811b1b 100644 --- a/app/api/client/[token]/project/route.ts +++ b/app/api/client/[token]/project/route.ts @@ -1,12 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; - -async function validateToken(token: string) { - const session = await db.reviewSession.findUnique({ where: { token } }); - if (!session || !session.isActive) return null; - if (session.expiresAt && session.expiresAt < new Date()) return null; - return session; -} +import { validateReviewToken } from "@/lib/review-auth"; /** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */ export async function GET( @@ -14,10 +8,14 @@ export async function GET( { params }: { params: Promise<{ token: string }> } ) { const { token } = await params; - const session = await validateToken(token); - if (!session) { + const result = await validateReviewToken(token, req); + if (result.type === "requiresPassword") { + return NextResponse.json({ requiresPassword: true }, { status: 401 }); + } + if (result.type === "invalid") { return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 }); } + const session = result.session; const project = await db.project.findUnique({ where: { id: session.projectId }, diff --git a/app/api/client/[token]/versions/[versionId]/route.ts b/app/api/client/[token]/versions/[versionId]/route.ts index 6b7d750..d6a565c 100644 --- a/app/api/client/[token]/versions/[versionId]/route.ts +++ b/app/api/client/[token]/versions/[versionId]/route.ts @@ -1,12 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; - -async function validateToken(token: string) { - const session = await db.reviewSession.findUnique({ where: { token } }); - if (!session || !session.isActive) return null; - if (session.expiresAt && session.expiresAt < new Date()) return null; - return session; -} +import { validateReviewToken } from "@/lib/review-auth"; /** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */ export async function GET( @@ -14,10 +8,14 @@ export async function GET( { params }: { params: Promise<{ token: string; versionId: string }> } ) { const { token, versionId } = await params; - const session = await validateToken(token); - if (!session) { + const result = await validateReviewToken(token, req); + if (result.type === "requiresPassword") { + return NextResponse.json({ requiresPassword: true }, { status: 401 }); + } + if (result.type === "invalid") { return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 }); } + const session = result.session; const version = await db.version.findUnique({ where: { id: versionId }, diff --git a/app/api/review-sessions/route.ts b/app/api/review-sessions/route.ts index 67cfb9d..beccbd6 100644 --- a/app/api/review-sessions/route.ts +++ b/app/api/review-sessions/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; import { addDays } from "date-fns"; +import bcrypt from "bcryptjs"; export async function GET(req: NextRequest) { const session = await auth(); @@ -32,7 +33,7 @@ export async function POST(req: NextRequest) { } const body = await req.json(); - const { projectId, label, email, expiresInDays = 30 } = body; + const { projectId, label, email, expiresInDays = 30, password } = body; if (!projectId) { return NextResponse.json({ error: "projectId is required" }, { status: 400 }); @@ -43,11 +44,17 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Project not found" }, { status: 404 }); } + const passwordHash = + password && typeof password === "string" && password.length > 0 + ? await bcrypt.hash(password, 12) + : null; + const reviewSession = await db.reviewSession.create({ data: { projectId, label: label || `Review — ${project.name}`, email: email || null, + passwordHash, expiresAt: addDays(new Date(), expiresInDays), }, }); diff --git a/app/client/[token]/page.tsx b/app/client/[token]/page.tsx index 5ccd808..0fb32aa 100644 --- a/app/client/[token]/page.tsx +++ b/app/client/[token]/page.tsx @@ -15,6 +15,7 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Montserrat } from 'next/font/google'; +import { ReviewPasswordGate } from '@/components/clients/ReviewPasswordGate'; const montserrat = Montserrat({ subsets: ['latin'], @@ -121,6 +122,7 @@ export default function ClientPortalPage({ const [sessionLabel, setSessionLabel] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [requiresPassword, setRequiresPassword] = useState(false); const [collapsedEpisodes, setCollapsedEpisodes] = useState>(new Set()); const toggleEpisode = (ep: string) => { @@ -134,22 +136,37 @@ export default function ClientPortalPage({ useEffect(() => { params.then(({ token: t }) => { setToken(t); - fetch(`/api/client/${t}/project`) - .then((r) => { - if (!r.ok) throw new Error('Invalid or expired review link'); - return r.json(); - }) - .then((data) => { - setProject(data.project); - setShots(data.shots ?? []); - setAssetTasks(data.assetTasks ?? []); - setSessionLabel(data.sessionLabel ?? ''); - }) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); + loadProject(t); }); }, [params]); + const loadProject = (t: string) => { + setLoading(true); + setError(null); + fetch(`/api/client/${t}/project`) + .then(async (r) => { + if (r.status === 401) { + const data = await r.json().catch(() => ({})); + if (data.requiresPassword) { + setRequiresPassword(true); + return null; + } + } + if (!r.ok) throw new Error('Invalid or expired review link'); + return r.json(); + }) + .then((data) => { + if (!data) return; + setProject(data.project); + setShots(data.shots ?? []); + setAssetTasks(data.assetTasks ?? []); + setSessionLabel(data.sessionLabel ?? ''); + setRequiresPassword(false); + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }; + if (loading) { return (
@@ -158,6 +175,15 @@ export default function ClientPortalPage({ ); } + if (requiresPassword) { + return ( + loadProject(token)} + /> + ); + } + if (error || !project) { return (
diff --git a/app/client/[token]/review/[versionId]/page.tsx b/app/client/[token]/review/[versionId]/page.tsx index 3e59bbe..24ef4eb 100644 --- a/app/client/[token]/review/[versionId]/page.tsx +++ b/app/client/[token]/review/[versionId]/page.tsx @@ -28,6 +28,7 @@ import { Clock, } from "lucide-react"; import { useReviewStore } from "@/hooks/use-review-player"; +import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate"; interface Comment { id: string; @@ -80,6 +81,8 @@ export default function ClientReviewPage({ const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [requiresPassword, setRequiresPassword] = useState(false); + const [versionId, setVersionId] = useState(""); const currentFrame = useReviewStore((s) => s.currentFrame); @@ -101,23 +104,39 @@ export default function ClientReviewPage({ const { toast } = useToast(); useEffect(() => { - params.then(({ token: t, versionId }) => { + params.then(({ token: t, versionId: vId }) => { setToken(t); - fetch(`/api/client/${t}/versions/${versionId}`) - .then((r) => { - if (!r.ok) throw new Error("Invalid or expired review link"); - return r.json(); - }) - .then((data) => { - setVersion(data.version); - setComments(data.comments); - setCurrentApprovalStatus(data.version.approvalStatus); - }) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); + setVersionId(vId); + loadVersion(t, vId); }); }, [params]); + const loadVersion = (t: string, vId: string) => { + setLoading(true); + setError(null); + fetch(`/api/client/${t}/versions/${vId}`) + .then(async (r) => { + if (r.status === 401) { + const data = await r.json().catch(() => ({})); + if (data.requiresPassword) { + setRequiresPassword(true); + return null; + } + } + if (!r.ok) throw new Error("Invalid or expired review link"); + return r.json(); + }) + .then((data) => { + if (!data) return; + setVersion(data.version); + setComments(data.comments); + setCurrentApprovalStatus(data.version.approvalStatus); + setRequiresPassword(false); + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }; + const refreshComments = useCallback(async (t: string, vId: string) => { const res = await fetch(`/api/client/${t}/versions/${vId}`); if (res.ok) { @@ -201,6 +220,15 @@ export default function ClientReviewPage({ ); } + if (requiresPassword) { + return ( + loadVersion(token, versionId)} + /> + ); + } + if (error || !version) { return (
diff --git a/components/clients/ReviewPasswordGate.tsx b/components/clients/ReviewPasswordGate.tsx new file mode 100644 index 0000000..e842f70 --- /dev/null +++ b/components/clients/ReviewPasswordGate.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { Film, Lock, Eye, EyeOff } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Montserrat } from "next/font/google"; + +const montserrat = Montserrat({ + subsets: ["latin"], + weight: ["200", "500", "600"], +}); + +interface ReviewPasswordGateProps { + token: string; + onUnlocked: () => void; +} + +export function ReviewPasswordGate({ token, onUnlocked }: ReviewPasswordGateProps) { + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!password.trim()) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/client/${token}/auth`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + if (res.ok) { + onUnlocked(); + } else { + const data = await res.json().catch(() => ({})); + setError(data.error ?? "Incorrect password. Please try again."); + } + } catch { + setError("Something went wrong. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+
+ Logo +
+
+ TWO TALES + + vfx review + +
+
+ + {/* Card */} +
+
+
+ +
+

Password Required

+

+ This review link is password protected. Enter the password to access the content. +

+
+ +
+
+ setPassword(e.target.value)} + className="pr-10 bg-zinc-950 border-zinc-700 focus:border-amber-500/50" + autoFocus + disabled={loading} + /> + +
+ + {error && ( +

+ + {error} +

+ )} + + +
+
+ +

+ Contact your studio if you don't have the password. +

+
+
+ ); +} diff --git a/components/clients/ShareReviewDialog.tsx b/components/clients/ShareReviewDialog.tsx index e4dd6a4..256a80a 100644 --- a/components/clients/ShareReviewDialog.tsx +++ b/components/clients/ShareReviewDialog.tsx @@ -17,7 +17,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/use-toast"; -import { Copy, Check, ExternalLink } from "lucide-react"; +import { Copy, Check, ExternalLink, Eye, EyeOff, Lock } from "lucide-react"; import { Select, SelectContent, @@ -31,6 +31,7 @@ const schema = z.object({ label: z.string().min(1, "Label is required"), email: z.string().email("Invalid email"), expiresInDays: z.number().int().positive().default(30), + password: z.string().optional(), }); type FormValues = z.infer; @@ -56,7 +57,9 @@ export function ShareReviewDialog({ const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [portalUrl, setPortalUrl] = useState(null); + const [isPasswordProtected, setIsPasswordProtected] = useState(false); const [copied, setCopied] = useState(false); + const [showPassword, setShowPassword] = useState(false); const { toast } = useToast(); const router = useRouter(); @@ -74,6 +77,7 @@ export function ShareReviewDialog({ label: "Review Round 1", email: clientEmail, expiresInDays: 30, + password: "", }, }); @@ -86,11 +90,13 @@ export function ShareReviewDialog({ const handleReset = () => { setPortalUrl(null); + setIsPasswordProtected(false); reset({ projectId: projects[0]?.id ?? "", label: "Review Round 1", email: clientEmail, expiresInDays: 30, + password: "", }); }; @@ -100,7 +106,10 @@ export function ShareReviewDialog({ const res = await fetch("/api/review-sessions", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(values), + body: JSON.stringify({ + ...values, + password: values.password?.trim() || undefined, + }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -108,6 +117,7 @@ export function ShareReviewDialog({ } const data = await res.json(); setPortalUrl(data.portalUrl); + setIsPasswordProtected(!!(values.password?.trim())); router.refresh(); } catch (e) { toast({ @@ -139,6 +149,12 @@ export function ShareReviewDialog({

Your review link is ready. Copy it and share it with your client.

+ {isPasswordProtected && ( +
+ + This link is password protected. Share the password separately. +
+ )}
{portalUrl} @@ -212,6 +228,33 @@ export function ShareReviewDialog({
+
+ +
+ + +
+