From c6ddc8c56d79fdf17d57a0ff605cfab33dc678ec Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Sun, 21 Jun 2026 03:55:45 -0700 Subject: [PATCH 1/2] fix: make video sharing links reliable --- app/routes/dashboard/-layout.tsx | 120 +++++++--- app/routes/dashboard/-project.tsx | 115 +++++----- convex/publicWatch.vitest.ts | 63 +++++ convex/videos.ts | 2 +- convex/workspace.ts | 18 +- src/components/ShareDialog.tsx | 370 +++++++++++++++++++++++------- src/lib/clipboard.test.ts | 127 ++++++++++ src/lib/clipboard.ts | 43 ++++ src/lib/dashboardAccess.test.ts | 96 ++++++++ src/lib/dashboardAccess.ts | 72 ++++++ src/lib/requestEpoch.test.ts | 29 +++ src/lib/requestEpoch.ts | 15 ++ 12 files changed, 890 insertions(+), 180 deletions(-) create mode 100644 src/lib/clipboard.test.ts create mode 100644 src/lib/clipboard.ts create mode 100644 src/lib/dashboardAccess.test.ts create mode 100644 src/lib/dashboardAccess.ts create mode 100644 src/lib/requestEpoch.test.ts create mode 100644 src/lib/requestEpoch.ts diff --git a/app/routes/dashboard/-layout.tsx b/app/routes/dashboard/-layout.tsx index a630c174..1f58c5c6 100644 --- a/app/routes/dashboard/-layout.tsx +++ b/app/routes/dashboard/-layout.tsx @@ -1,5 +1,5 @@ import { useAuth } from "@clerk/tanstack-react-start"; -import { useConvex, useQuery } from "convex/react"; +import { useConvex, useConvexAuth, useQuery } from "convex/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "@convex/_generated/api"; import type { Id } from "@convex/_generated/dataModel"; @@ -16,8 +16,9 @@ import { import { UploadProgress } from "@/components/upload/UploadProgress"; import { useVideoUploadManager } from "./-useVideoUploadManager"; import { DashboardUploadProvider } from "@/lib/dashboardUploadContext"; -import { videoPath } from "@/lib/routes"; +import { videoPath, watchPath } from "@/lib/routes"; import { prewarmVideo } from "./-video.data"; +import { resolveDashboardAccess } from "@/lib/dashboardAccess"; const VIDEO_FILE_EXTENSIONS = /\.(mp4|mov|m4v|webm|avi|mkv)$/i; @@ -31,25 +32,44 @@ function dragEventHasFiles(event: DragEvent) { export default function DashboardLayout() { const { isLoaded, userId } = useAuth(); + const { isLoading: isConvexAuthLoading, isAuthenticated: isConvexAuthenticated } = + useConvexAuth(); const convex = useConvex(); const navigate = useNavigate({}); const location = useLocation(); const { pathname, searchStr } = location; const params = useParams({ strict: false }); const teamSlug = typeof params.teamSlug === "string" ? params.teamSlug : undefined; - const routeProjectId = - typeof params.projectId === "string" ? (params.projectId as Id<"projects">) : undefined; - const routeVideoId = - typeof params.videoId === "string" ? (params.videoId as Id<"videos">) : undefined; + const rawProjectId = typeof params.projectId === "string" ? params.projectId : undefined; + const rawVideoId = typeof params.videoId === "string" ? params.videoId : undefined; const publicPlaybackId = useQuery( api.videos.getPublicIdByVideoId, - routeVideoId ? { videoId: routeVideoId } : "skip", + rawVideoId ? { videoId: rawVideoId } : "skip", ); - const detailVideo = useQuery( - api.videos.get, - routeVideoId && userId ? { videoId: routeVideoId } : "skip", + const contextRequired = Boolean(teamSlug || rawProjectId || rawVideoId); + const workspaceContext = useQuery( + api.workspace.resolveContext, + isLoaded && Boolean(userId) && !isConvexAuthLoading && isConvexAuthenticated && contextRequired + ? { teamSlug, projectId: rawProjectId, videoId: rawVideoId } + : "skip", + ); + const access = resolveDashboardAccess({ + clerkLoaded: isLoaded, + hasClerkUser: Boolean(userId), + convexAuthLoading: isConvexAuthLoading, + convexAuthenticated: isConvexAuthenticated, + contextRequired, + workspaceContext, + publicLookupRequired: Boolean(rawVideoId), + publicId: publicPlaybackId, + }); + const routeProjectId = access.kind === "dashboard" ? workspaceContext?.project?._id : undefined; + const routeVideoId = access.kind === "dashboard" ? workspaceContext?.video?._id : undefined; + const detailVideo = useQuery(api.videos.get, routeVideoId ? { videoId: routeVideoId } : "skip"); + const uploadTargets = useQuery( + api.projects.listUploadTargets, + access.kind === "dashboard" ? (teamSlug ? { teamSlug } : {}) : "skip", ); - const uploadTargets = useQuery(api.projects.listUploadTargets, teamSlug ? { teamSlug } : {}); const { uploads, uploadFilesToProject, uploadNewVersion, cancelUpload, retryProcessing } = useVideoUploadManager(); const [isGlobalDragActive, setIsGlobalDragActive] = useState(false); @@ -217,40 +237,80 @@ export default function DashboardLayout() { }), [requestUpload, requestVersionUpload, uploads, cancelUpload, retryProcessing], ); - const isResolvingPublicPlaybackExemption = - Boolean(isLoaded && !userId && routeVideoId) && publicPlaybackId === undefined; + const publicRedirectId = access.kind === "redirect-public" ? access.publicId : undefined; + const shouldRedirectToSignIn = access.kind === "redirect-sign-in"; useEffect(() => { - if (!isLoaded || userId) return; if (typeof window === "undefined") return; - if (routeVideoId) { - if (publicPlaybackId === undefined) return; - if (publicPlaybackId) { - window.location.replace(`/watch/${publicPlaybackId}`); - return; - } + if (publicRedirectId) { + window.location.replace(watchPath(publicRedirectId)); + return; } - const redirectUrl = `${pathname}${searchStr}`; - window.location.replace(`/sign-in?redirect_url=${encodeURIComponent(redirectUrl)}`); - }, [isLoaded, userId, pathname, searchStr, routeVideoId, publicPlaybackId]); + if (shouldRedirectToSignIn) { + const redirectUrl = `${pathname}${searchStr}`; + window.location.replace(`/sign-in?redirect_url=${encodeURIComponent(redirectUrl)}`); + } + }, [pathname, publicRedirectId, searchStr, shouldRedirectToSignIn]); - if (!isLoaded) { + if (access.kind === "loading") { return (
-
Loading...
+
+ Checking access... +
); } - if (!userId) { + if (access.kind === "redirect-public" || access.kind === "redirect-sign-in") { return (
-
- {isResolvingPublicPlaybackExemption - ? "Checking public playback access..." - : "Redirecting to sign in..."} +
+ Redirecting... +
+
+ ); + } + + if (access.kind === "auth-unavailable") { + return ( +
+
+

We couldn't verify your dashboard session.

+

Try again, or return home and sign in again.

+
+ + + Go home + +
+
+
+ ); + } + + if (access.kind === "not-found") { + return ( +
+
+

Video or workspace not found

+ + Back to dashboard +
); diff --git a/app/routes/dashboard/-project.tsx b/app/routes/dashboard/-project.tsx index f3e29a42..8e6f8480 100644 --- a/app/routes/dashboard/-project.tsx +++ b/app/routes/dashboard/-project.tsx @@ -38,7 +38,8 @@ import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Id } from "@convex/_generated/dataModel"; import { cn } from "@/lib/utils"; -import { projectPath, teamHomePath, videoPath } from "@/lib/routes"; +import { projectPath, teamHomePath, videoPath, watchPath } from "@/lib/routes"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { ProjectCard } from "@/components/projects/ProjectCard"; import { MoveProjectDialog } from "@/components/projects/MoveProjectDialog"; import { MoveVideoDialog } from "@/components/videos/MoveVideoDialog"; @@ -56,42 +57,15 @@ import { prewarmTeam } from "./-team.data"; import { prewarmVideo } from "./-video.data"; import { useDashboardUploadContext } from "@/lib/dashboardUploadContext"; import { DashboardHeader } from "@/components/DashboardHeader"; +import { createRequestEpoch } from "@/lib/requestEpoch"; type ViewMode = "grid" | "list"; type ShareToastState = { tone: "success" | "error"; message: string; + url?: string; }; -async function copyTextToClipboard(text: string) { - if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return true; - } - - if (typeof document === "undefined") { - return false; - } - - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.opacity = "0"; - textarea.style.pointerEvents = "none"; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - - let copied = false; - try { - copied = document.execCommand("copy"); - } finally { - document.body.removeChild(textarea); - } - - return copied; -} - type VideoIntentTargetProps = { className: string; teamSlug: string; @@ -193,6 +167,9 @@ export default function ProjectPage({ const [viewMode, setViewMode] = useState("grid"); const [shareToast, setShareToast] = useState(null); const shareToastTimeoutRef = useRef(null); + const shareRequestEpochRef = useRef(createRequestEpoch()); + const sharePendingRef = useRef(false); + const [sharePending, setSharePending] = useState(false); const [createFolderOpen, setCreateFolderOpen] = useState(false); const [newFolderName, setNewFolderName] = useState(""); const [isCreatingFolder, setIsCreatingFolder] = useState(false); @@ -235,6 +212,7 @@ export default function ProjectPage({ useEffect( () => () => { + shareRequestEpochRef.current.invalidate(); if (shareToastTimeoutRef.current !== null) { window.clearTimeout(shareToastTimeoutRef.current); } @@ -332,46 +310,54 @@ export default function ProjectPage({ [updateVideoWorkflowStatus], ); - const showShareToast = useCallback((tone: ShareToastState["tone"], message: string) => { - setShareToast({ tone, message }); + const showShareToast = useCallback((toast: ShareToastState, autoDismiss: boolean) => { + setShareToast(toast); if (shareToastTimeoutRef.current !== null) { window.clearTimeout(shareToastTimeoutRef.current); - } - shareToastTimeoutRef.current = window.setTimeout(() => { - setShareToast(null); shareToastTimeoutRef.current = null; - }, 2400); + } + if (autoDismiss) { + shareToastTimeoutRef.current = window.setTimeout(() => { + setShareToast(null); + shareToastTimeoutRef.current = null; + }, 2400); + } }, []); const handleShareVideo = useCallback( - async (video: { - _id: Id<"videos">; - publicId?: string; - status: string; - visibility: "public" | "private"; - }) => { - const canSharePublicly = - Boolean(video.publicId) && video.status === "ready" && video.visibility === "public"; - const path = canSharePublicly - ? `/watch/${video.publicId}` - : videoPath(resolvedTeamSlug, projectId, video._id); + async (video: { _id: Id<"videos">; publicId?: string; visibility: "public" | "private" }) => { + if (sharePendingRef.current) return; + sharePendingRef.current = true; + setSharePending(true); + setShareToast(null); + const requestEpoch = shareRequestEpochRef.current.next(); + const publicWatchPath = + video.visibility === "public" && video.publicId ? watchPath(video.publicId) : null; + const path = publicWatchPath ?? videoPath(resolvedTeamSlug, projectId, video._id); const origin = typeof window !== "undefined" ? window.location.origin : ""; const url = `${origin}${path}`; try { const copied = await copyTextToClipboard(url); + if (!shareRequestEpochRef.current.isCurrent(requestEpoch)) return; + sharePendingRef.current = false; + setSharePending(false); if (!copied) { - showShareToast("error", "Could not copy link"); + showShareToast({ tone: "error", message: "Could not copy link", url }, false); return; } showShareToast( - "success", - canSharePublicly - ? "Share link copied" - : "Video link copied (public watch link not available yet)", + { + tone: "success", + message: publicWatchPath ? "Share link copied" : "Private dashboard link copied", + }, + true, ); } catch { - showShareToast("error", "Could not copy link"); + if (!shareRequestEpochRef.current.isCurrent(requestEpoch)) return; + sharePendingRef.current = false; + setSharePending(false); + showShareToast({ tone: "error", message: "Could not copy link", url }, false); } }, [projectId, resolvedTeamSlug, showShareToast], @@ -635,6 +621,7 @@ export default function ProjectPage({ )} { e.stopPropagation(); void handleShareVideo(video); @@ -854,6 +841,7 @@ export default function ProjectPage({ )} { e.stopPropagation(); void handleShareVideo(video); @@ -912,8 +900,10 @@ export default function ProjectPage({
{shareToast ? ( -
+
- {shareToast.message} +

{shareToast.message}

+ {shareToast.url ? ( +
+ event.currentTarget.select()} + onClick={(event) => event.currentTarget.select()} + /> + +
+ ) : null}
) : null} diff --git a/convex/publicWatch.vitest.ts b/convex/publicWatch.vitest.ts index df3945c0..58151cf6 100644 --- a/convex/publicWatch.vitest.ts +++ b/convex/publicWatch.vitest.ts @@ -236,3 +236,66 @@ test("returns null for an unknown public id", async () => { const result = await t.query(api.videos.getByPublicId, { publicId: "does-not-exist" }); expect(result).toBeNull(); }); + +test("dashboard aliases resolve public ids throughout the upload lifecycle", async () => { + const { t, projectId } = await seedPublicStack(); + + for (const status of ["uploading", "processing", "ready", "failed"] as const) { + const videoId = await t.run((ctx) => + ctx.db.insert("videos", { + projectId, + uploadedByClerkId: "user_1", + uploaderName: "Owner", + title: `${status} video`, + visibility: "public", + publicId: `alias-${status}`, + status, + workflowStatus: "review", + }), + ); + + await expect(t.query(api.videos.getPublicIdByVideoId, { videoId })).resolves.toBe( + `alias-${status}`, + ); + } +}); + +test("dashboard aliases do not reveal private, missing, or malformed video ids", async () => { + const { t, projectId } = await seedPublicStack(); + const privateVideoId = await t.run((ctx) => + ctx.db.insert("videos", { + projectId, + uploadedByClerkId: "user_1", + uploaderName: "Owner", + title: "Private video", + visibility: "private", + publicId: "private-alias", + status: "ready", + workflowStatus: "review", + }), + ); + const missingVideoId = await t.run(async (ctx) => { + const videoId = await ctx.db.insert("videos", { + projectId, + uploadedByClerkId: "user_1", + uploaderName: "Owner", + title: "Deleted video", + visibility: "public", + publicId: "deleted-alias", + status: "ready", + workflowStatus: "review", + }); + await ctx.db.delete(videoId); + return videoId; + }); + + await expect( + t.query(api.videos.getPublicIdByVideoId, { videoId: privateVideoId }), + ).resolves.toBeNull(); + await expect( + t.query(api.videos.getPublicIdByVideoId, { videoId: missingVideoId }), + ).resolves.toBeNull(); + await expect( + t.query(api.videos.getPublicIdByVideoId, { videoId: "not-a-convex-id" }), + ).resolves.toBeNull(); +}); diff --git a/convex/videos.ts b/convex/videos.ts index f1233368..3c2e62ee 100644 --- a/convex/videos.ts +++ b/convex/videos.ts @@ -831,7 +831,7 @@ export const getPublicIdByVideoId = query({ } const video = await ctx.db.get(normalizedVideoId); - if (!video || video.visibility !== "public" || video.status !== "ready" || !video.publicId) { + if (!video || video.visibility !== "public" || !video.publicId) { return null; } diff --git a/convex/workspace.ts b/convex/workspace.ts index a50b923b..508455cb 100644 --- a/convex/workspace.ts +++ b/convex/workspace.ts @@ -18,19 +18,25 @@ function buildCanonicalPath(input: { teamSlug: string; projectId?: string; video export const resolveContext = query({ args: { teamSlug: v.optional(v.string()), - projectId: v.optional(v.id("projects")), - videoId: v.optional(v.id("videos")), + projectId: v.optional(v.string()), + videoId: v.optional(v.string()), }, handler: async (ctx, args) => { const user = await getUser(ctx); if (!user) return null; + const projectId = args.projectId ? ctx.db.normalizeId("projects", args.projectId) : undefined; + const videoId = args.videoId ? ctx.db.normalizeId("videos", args.videoId) : undefined; + if ((args.projectId && !projectId) || (args.videoId && !videoId)) { + return null; + } + let team: Doc<"teams"> | null = null; let project: Doc<"projects"> | null = null; let video: Doc<"videos"> | null = null; - if (args.videoId) { - video = await ctx.db.get(args.videoId); + if (videoId) { + video = await ctx.db.get(videoId); if (!video) return null; project = await ctx.db.get(video.projectId); @@ -38,8 +44,8 @@ export const resolveContext = query({ team = await ctx.db.get(project.teamId); if (!team) return null; - } else if (args.projectId) { - project = await ctx.db.get(args.projectId); + } else if (projectId) { + project = await ctx.db.get(projectId); if (!project) return null; team = await ctx.db.get(project.teamId); diff --git a/src/components/ShareDialog.tsx b/src/components/ShareDialog.tsx index 818ff797..cf4907c9 100644 --- a/src/components/ShareDialog.tsx +++ b/src/components/ShareDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useQuery, useMutation } from "convex/react"; import { api } from "../../convex/_generated/api"; import { Id } from "../../convex/_generated/dataModel"; @@ -23,6 +23,9 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn, formatRelativeTime } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/clipboard"; +import { watchPath } from "@/lib/routes"; +import { createRequestEpoch } from "@/lib/requestEpoch"; interface ShareDialogProps { videoId: Id<"videos">; @@ -41,14 +44,88 @@ export function ShareDialog({ videoId, open, onOpenChange }: ShareDialogProps) { const [isCreating, setIsCreating] = useState(false); const [isUpdatingVisibility, setIsUpdatingVisibility] = useState(false); const [isUpdatingVersionBrowsing, setIsUpdatingVersionBrowsing] = useState(false); + const [deletingLinkId, setDeletingLinkId] = useState | null>(null); const [copiedId, setCopiedId] = useState(null); + const [copyStatus, setCopyStatus] = useState<{ + tone: "success" | "error"; + message: string; + } | null>(null); + const [copyFailureUrl, setCopyFailureUrl] = useState(null); + const [mutationError, setMutationError] = useState(null); const [newLinkOptions, setNewLinkOptions] = useState({ expiresInDays: undefined as number | undefined, password: undefined as string | undefined, }); + const copySequenceRef = useRef(0); + const copyTimeoutRef = useRef(null); + const mutationContextEpochRef = useRef(createRequestEpoch()); + const activeVideoIdRef = useRef(videoId); + const activeOpenRef = useRef(open); + activeVideoIdRef.current = videoId; + activeOpenRef.current = open; + + const isViewer = video?.role === "viewer"; + const canManageSharing = video !== undefined && !isViewer; + + const clearCopyFeedback = useCallback(() => { + copySequenceRef.current += 1; + if (copyTimeoutRef.current !== null) { + window.clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } + setCopiedId(null); + setCopyStatus(null); + setCopyFailureUrl(null); + }, []); + + const resetMutationState = useCallback(() => { + setIsCreating(false); + setIsUpdatingVisibility(false); + setIsUpdatingVersionBrowsing(false); + setDeletingLinkId(null); + setMutationError(null); + setNewLinkOptions({ expiresInDays: undefined, password: undefined }); + }, []); + + const isMutationCurrent = useCallback( + (contextEpoch: number, requestVideoId: Id<"videos">) => + mutationContextEpochRef.current.isCurrent(contextEpoch) && + activeVideoIdRef.current === requestVideoId && + activeOpenRef.current, + [], + ); + + useEffect(() => { + mutationContextEpochRef.current.invalidate(); + clearCopyFeedback(); + resetMutationState(); + + return () => { + mutationContextEpochRef.current.invalidate(); + copySequenceRef.current += 1; + if (copyTimeoutRef.current !== null) { + window.clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } + }; + }, [clearCopyFeedback, open, resetMutationState, videoId]); + + const handleDialogOpenChange = (nextOpen: boolean) => { + activeOpenRef.current = nextOpen; + if (!nextOpen) { + mutationContextEpochRef.current.invalidate(); + clearCopyFeedback(); + resetMutationState(); + } + onOpenChange(nextOpen); + }; const handleCreateLink = async () => { + if (!canManageSharing) return; + const contextEpoch = mutationContextEpochRef.current.current(); + const requestVideoId = videoId; setIsCreating(true); + setMutationError(null); try { await createShareLink({ videoId, @@ -56,71 +133,132 @@ export function ShareDialog({ videoId, open, onOpenChange }: ShareDialogProps) { allowDownload: false, password: newLinkOptions.password, }); + if (!isMutationCurrent(contextEpoch, requestVideoId)) return; setNewLinkOptions({ expiresInDays: undefined, password: undefined, }); - } catch (error) { - console.error("Failed to create share link:", error); + } catch { + if (!isMutationCurrent(contextEpoch, requestVideoId)) return; + setMutationError("Could not create the restricted link. Please try again."); } finally { - setIsCreating(false); + if (isMutationCurrent(contextEpoch, requestVideoId)) { + setIsCreating(false); + } } }; const handleSetVisibility = async (visibility: "public" | "private") => { - if (!video || isUpdatingVisibility || video.visibility === visibility) return; + if (!video || !canManageSharing || isUpdatingVisibility || video.visibility === visibility) + return; + const contextEpoch = mutationContextEpochRef.current.current(); + const requestVideoId = videoId; setIsUpdatingVisibility(true); + setMutationError(null); try { await setVisibility({ videoId, visibility }); - } catch (error) { - console.error("Failed to update visibility:", error); + } catch { + if (!isMutationCurrent(contextEpoch, requestVideoId)) return; + setMutationError("Could not update video visibility. Please try again."); } finally { - setIsUpdatingVisibility(false); + if (isMutationCurrent(contextEpoch, requestVideoId)) { + setIsUpdatingVisibility(false); + } } }; const versionBrowsingEnabled = video?.allowPublicVersionBrowsing !== false; const handleSetVersionBrowsing = async (enabled: boolean) => { - if (!video || isUpdatingVersionBrowsing || versionBrowsingEnabled === enabled) return; + if ( + !video || + !canManageSharing || + isUpdatingVersionBrowsing || + versionBrowsingEnabled === enabled + ) + return; + const contextEpoch = mutationContextEpochRef.current.current(); + const requestVideoId = videoId; setIsUpdatingVersionBrowsing(true); + setMutationError(null); try { await setVersionBrowsing({ videoId, enabled }); - } catch (error) { - console.error("Failed to update version browsing:", error); + } catch { + if (!isMutationCurrent(contextEpoch, requestVideoId)) return; + setMutationError("Could not update version browsing. Please try again."); } finally { - setIsUpdatingVersionBrowsing(false); + if (isMutationCurrent(contextEpoch, requestVideoId)) { + setIsUpdatingVersionBrowsing(false); + } + } + }; + + const copyLink = async (id: string, path: string) => { + const requestId = copySequenceRef.current + 1; + copySequenceRef.current = requestId; + if (copyTimeoutRef.current !== null) { + window.clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } + setCopiedId(null); + setCopyStatus(null); + setCopyFailureUrl(null); + const url = `${window.location.origin}${path}`; + const copied = await copyTextToClipboard(url); + if (requestId !== copySequenceRef.current || activeVideoIdRef.current !== videoId || !open) { + return; + } + if (!copied) { + setCopyStatus({ + tone: "error", + message: "Could not copy the link. Please copy it manually.", + }); + setCopyFailureUrl(url); + return; } + + setCopiedId(id); + setCopyStatus({ tone: "success", message: "Link copied to clipboard." }); + copyTimeoutRef.current = window.setTimeout(() => { + if (copySequenceRef.current === requestId) { + setCopiedId((current) => (current === id ? null : current)); + } + copyTimeoutRef.current = null; + }, 2000); }; - const handleCopyLink = (token: string) => { - const url = `${window.location.origin}/share/${token}`; - navigator.clipboard.writeText(url); - setCopiedId(token); - setTimeout(() => setCopiedId(null), 2000); + const handleCopyLink = async (token: string) => { + await copyLink(token, `/share/${token}`); }; - const handleCopyPublicLink = () => { + const handleCopyPublicLink = async () => { if (!video?.publicId) return; - const url = `${window.location.origin}/watch/${video.publicId}`; - navigator.clipboard.writeText(url); - setCopiedId("public"); - setTimeout(() => setCopiedId(null), 2000); + await copyLink("public", watchPath(video.publicId)); }; const handleDeleteLink = async (linkId: Id<"shareLinks">) => { + if (!canManageSharing) return; if (!confirm("Are you sure you want to delete this share link?")) return; + const contextEpoch = mutationContextEpochRef.current.current(); + const requestVideoId = videoId; + setDeletingLinkId(linkId); + setMutationError(null); try { await deleteShareLink({ linkId }); - } catch (error) { - console.error("Failed to delete share link:", error); + } catch { + if (!isMutationCurrent(contextEpoch, requestVideoId)) return; + setMutationError("Could not delete the restricted link. Please try again."); + } finally { + if (isMutationCurrent(contextEpoch, requestVideoId)) { + setDeletingLinkId(null); + } } }; - const publicWatchPath = video?.publicId ? `/watch/${video.publicId}` : null; + const publicWatchPath = video?.publicId ? watchPath(video.publicId) : null; return ( - + Share video @@ -129,12 +267,51 @@ export function ShareDialog({ videoId, open, onOpenChange }: ShareDialogProps) { + {isViewer ? ( +

+ View-only access: you can copy or open links, but only team members can change sharing + settings. +

+ ) : null} + + {mutationError ? ( +

+ {mutationError} +

+ ) : null} + + {copyStatus ? ( +
+

{copyStatus.message}

+ {copyFailureUrl ? ( + event.currentTarget.select()} + onClick={(event) => event.currentTarget.select()} + /> + ) : null} +
+ ) : null} + {/* Visibility */}
-
- - - - - - setNewLinkOptions((o) => ({ ...o, expiresInDays: undefined }))} - > - Never expires - - setNewLinkOptions((o) => ({ ...o, expiresInDays: 1 }))} - > - 1 day - - setNewLinkOptions((o) => ({ ...o, expiresInDays: 7 }))} - > - 7 days - - setNewLinkOptions((o) => ({ ...o, expiresInDays: 30 }))} - > - 30 days - - - - - setNewLinkOptions((o) => ({ - ...o, - password: e.target.value || undefined, - })) - } - /> -
+ {canManageSharing ? ( + <> +
+ + + + + + setNewLinkOptions((o) => ({ ...o, expiresInDays: undefined }))} + > + Never expires + + setNewLinkOptions((o) => ({ ...o, expiresInDays: 1 }))} + > + 1 day + + setNewLinkOptions((o) => ({ ...o, expiresInDays: 7 }))} + > + 7 days + + setNewLinkOptions((o) => ({ ...o, expiresInDays: 30 }))} + > + 30 days + + + + + setNewLinkOptions((o) => ({ + ...o, + password: e.target.value || undefined, + })) + } + /> +
- + + + ) : null} {shareLinks === undefined ? ( -

Loading...

+

+ Loading links... +

) : shareLinks.length === 0 ? (

No share links yet

) : ( @@ -316,7 +500,12 @@ export function ShareDialog({ videoId, open, onOpenChange }: ShareDialogProps) {
- - + {canManageSharing ? ( + + ) : null}
))} diff --git a/src/lib/clipboard.test.ts b/src/lib/clipboard.test.ts new file mode 100644 index 00000000..1c58440d --- /dev/null +++ b/src/lib/clipboard.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { copyTextToClipboard } from "./clipboard"; + +const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); +const documentDescriptor = Object.getOwnPropertyDescriptor(globalThis, "document"); + +function setGlobal(name: "navigator" | "document", value: unknown) { + Object.defineProperty(globalThis, name, { configurable: true, value }); +} + +function restoreGlobal(name: "navigator" | "document", descriptor?: PropertyDescriptor) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + Reflect.deleteProperty(globalThis, name); + } +} + +afterEach(() => { + restoreGlobal("navigator", navigatorDescriptor); + restoreGlobal("document", documentDescriptor); +}); + +test("awaits the native Clipboard API before reporting success", async () => { + let written = ""; + setGlobal("navigator", { + clipboard: { + writeText: async (text: string) => { + written = text; + }, + }, + }); + setGlobal("document", undefined); + + assert.equal(await copyTextToClipboard("https://lawn.video/watch/one"), true); + assert.equal(written, "https://lawn.video/watch/one"); +}); + +test("falls back to execCommand when the native Clipboard API rejects", async () => { + let appended = false; + let removed = false; + let copiedValue = ""; + const textarea = { + value: "", + style: {}, + setAttribute: () => undefined, + focus: () => undefined, + select: () => { + copiedValue = textarea.value; + }, + }; + + setGlobal("navigator", { + clipboard: { writeText: async () => Promise.reject(new Error("permission denied")) }, + }); + setGlobal("document", { + body: { + appendChild: () => { + appended = true; + }, + removeChild: () => { + removed = true; + }, + }, + createElement: () => textarea, + execCommand: () => true, + }); + + assert.equal(await copyTextToClipboard("fallback text"), true); + assert.equal(copiedValue, "fallback text"); + assert.equal(appended, true); + assert.equal(removed, true); +}); + +test("reports failure and still cleans up when both clipboard paths fail", async () => { + let removed = false; + const textarea = { + value: "", + style: {}, + setAttribute: () => undefined, + focus: () => undefined, + select: () => undefined, + }; + + setGlobal("navigator", { + clipboard: { writeText: async () => Promise.reject(new Error("permission denied")) }, + }); + setGlobal("document", { + body: { + appendChild: () => undefined, + removeChild: () => { + removed = true; + }, + }, + createElement: () => textarea, + execCommand: () => { + throw new Error("copy unsupported"); + }, + }); + + assert.equal(await copyTextToClipboard("uncopyable"), false); + assert.equal(removed, true); +}); + +test("reports failure when the fallback cannot be mounted", async () => { + setGlobal("navigator", undefined); + setGlobal("document", { + body: { + appendChild: () => { + throw new Error("document is no longer active"); + }, + removeChild: () => undefined, + }, + createElement: () => ({ + value: "", + style: {}, + setAttribute: () => undefined, + focus: () => undefined, + select: () => undefined, + }), + execCommand: () => true, + }); + + assert.equal(await copyTextToClipboard("uncopyable"), false); +}); diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 00000000..25535eda --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,43 @@ +export async function copyTextToClipboard(text: string) { + try { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Some browsers expose the Clipboard API but reject it outside a secure or + // user-activated context. Fall through to the legacy selection path. + } + + if (typeof document === "undefined" || !document.body) { + return false; + } + + let textarea: HTMLTextAreaElement | undefined; + let appended = false; + + try { + textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + document.body.appendChild(textarea); + appended = true; + textarea.focus(); + textarea.select(); + return document.execCommand("copy"); + } catch { + return false; + } finally { + if (textarea && appended) { + try { + document.body.removeChild(textarea); + } catch { + // The copy result is still authoritative if another listener already + // removed the temporary element. + } + } + } +} diff --git a/src/lib/dashboardAccess.test.ts b/src/lib/dashboardAccess.test.ts new file mode 100644 index 00000000..d998af1c --- /dev/null +++ b/src/lib/dashboardAccess.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveDashboardAccess } from "./dashboardAccess"; + +const authenticated = { + clerkLoaded: true, + hasClerkUser: true, + convexAuthLoading: false, + convexAuthenticated: true, + contextRequired: true, + workspaceContext: {}, + publicLookupRequired: true, + publicId: "public-video", +} as const; + +test("waits for Convex authentication before allowing protected dashboard content", () => { + assert.deepEqual( + resolveDashboardAccess({ + ...authenticated, + convexAuthLoading: true, + convexAuthenticated: false, + workspaceContext: undefined, + }), + { kind: "loading" }, + ); + assert.deepEqual( + resolveDashboardAccess({ + ...authenticated, + convexAuthenticated: false, + workspaceContext: undefined, + publicId: null, + }), + { kind: "auth-unavailable" }, + ); +}); + +test("uses public playback when Clerk has a user but Convex auth settles unauthenticated", () => { + assert.deepEqual( + resolveDashboardAccess({ + ...authenticated, + convexAuthenticated: false, + workspaceContext: undefined, + }), + { kind: "redirect-public", publicId: "public-video" }, + ); + assert.deepEqual( + resolveDashboardAccess({ + ...authenticated, + convexAuthenticated: false, + workspaceContext: undefined, + publicId: undefined, + }), + { kind: "loading" }, + ); +}); + +test("keeps authenticated team members on the dashboard", () => { + assert.deepEqual(resolveDashboardAccess(authenticated), { kind: "dashboard" }); +}); + +test("redirects anonymous viewers and authenticated non-members to public playback", () => { + assert.deepEqual( + resolveDashboardAccess({ + ...authenticated, + hasClerkUser: false, + convexAuthenticated: false, + workspaceContext: undefined, + }), + { kind: "redirect-public", publicId: "public-video" }, + ); + assert.deepEqual(resolveDashboardAccess({ ...authenticated, workspaceContext: null }), { + kind: "redirect-public", + publicId: "public-video", + }); +}); + +test("does not expose private or missing videos to authenticated non-members", () => { + assert.deepEqual( + resolveDashboardAccess({ ...authenticated, workspaceContext: null, publicId: null }), + { kind: "not-found" }, + ); +}); + +test("sends anonymous private links to sign in without mounting dashboard content", () => { + assert.deepEqual( + resolveDashboardAccess({ + ...authenticated, + hasClerkUser: false, + convexAuthenticated: false, + workspaceContext: undefined, + publicId: null, + }), + { kind: "redirect-sign-in" }, + ); +}); diff --git a/src/lib/dashboardAccess.ts b/src/lib/dashboardAccess.ts new file mode 100644 index 00000000..d4fb56bb --- /dev/null +++ b/src/lib/dashboardAccess.ts @@ -0,0 +1,72 @@ +type WorkspaceContext = object | null | undefined; + +type DashboardAccessInput = { + clerkLoaded: boolean; + hasClerkUser: boolean; + convexAuthLoading: boolean; + convexAuthenticated: boolean; + contextRequired: boolean; + workspaceContext: WorkspaceContext; + publicLookupRequired: boolean; + publicId: string | null | undefined; +}; + +export type DashboardAccessState = + | { kind: "loading" } + | { kind: "dashboard" } + | { kind: "redirect-public"; publicId: string } + | { kind: "redirect-sign-in" } + | { kind: "not-found" } + | { kind: "auth-unavailable" }; + +export function resolveDashboardAccess(input: DashboardAccessInput): DashboardAccessState { + if (!input.clerkLoaded) { + return { kind: "loading" }; + } + + if (!input.hasClerkUser) { + if (input.publicLookupRequired && input.publicId === undefined) { + return { kind: "loading" }; + } + if (input.publicLookupRequired && input.publicId) { + return { kind: "redirect-public", publicId: input.publicId }; + } + return { kind: "redirect-sign-in" }; + } + + if (input.convexAuthLoading) { + return { kind: "loading" }; + } + + if (!input.convexAuthenticated) { + if (input.publicLookupRequired && input.publicId === undefined) { + return { kind: "loading" }; + } + if (input.publicLookupRequired && input.publicId) { + return { kind: "redirect-public", publicId: input.publicId }; + } + return { kind: "auth-unavailable" }; + } + + if (!input.contextRequired) { + return { kind: "dashboard" }; + } + + if (input.workspaceContext === undefined) { + return { kind: "loading" }; + } + + if (input.workspaceContext !== null) { + return { kind: "dashboard" }; + } + + if (input.publicLookupRequired && input.publicId === undefined) { + return { kind: "loading" }; + } + + if (input.publicLookupRequired && input.publicId) { + return { kind: "redirect-public", publicId: input.publicId }; + } + + return { kind: "not-found" }; +} diff --git a/src/lib/requestEpoch.test.ts b/src/lib/requestEpoch.test.ts new file mode 100644 index 00000000..ba3ea77a --- /dev/null +++ b/src/lib/requestEpoch.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createRequestEpoch } from "./requestEpoch"; + +test("a newer request invalidates an older delayed completion", async () => { + const epoch = createRequestEpoch(); + const first = epoch.next(); + let resolveFirst = () => undefined; + const firstResult = new Promise((resolve) => { + resolveFirst = resolve; + }); + + const second = epoch.next(); + resolveFirst(); + await firstResult; + + assert.equal(epoch.isCurrent(first), false); + assert.equal(epoch.isCurrent(second), true); +}); + +test("context invalidation rejects a delayed mutation token", () => { + const epoch = createRequestEpoch(); + const context = epoch.current(); + + epoch.invalidate(); + + assert.equal(epoch.isCurrent(context), false); +}); diff --git a/src/lib/requestEpoch.ts b/src/lib/requestEpoch.ts new file mode 100644 index 00000000..11778822 --- /dev/null +++ b/src/lib/requestEpoch.ts @@ -0,0 +1,15 @@ +export function createRequestEpoch() { + let epoch = 0; + + return { + current: () => epoch, + next: () => { + epoch += 1; + return epoch; + }, + invalidate: () => { + epoch += 1; + }, + isCurrent: (requestEpoch: number) => requestEpoch === epoch, + }; +} From 16039fd44ccf27c6c2f96c962042f53cbb710a64 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Sun, 21 Jun 2026 04:03:56 -0700 Subject: [PATCH 2/2] fix: guard unavailable share data --- src/components/ShareDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ShareDialog.tsx b/src/components/ShareDialog.tsx index cf4907c9..92cf601c 100644 --- a/src/components/ShareDialog.tsx +++ b/src/components/ShareDialog.tsx @@ -65,7 +65,7 @@ export function ShareDialog({ videoId, open, onOpenChange }: ShareDialogProps) { activeOpenRef.current = open; const isViewer = video?.role === "viewer"; - const canManageSharing = video !== undefined && !isViewer; + const canManageSharing = video != null && !isViewer; const clearCopyFeedback = useCallback(() => { copySequenceRef.current += 1;