diff --git a/app/components/draft/AuthRecoveryOverlay.tsx b/app/components/draft/AuthRecoveryOverlay.tsx new file mode 100644 index 0000000..a0a2935 --- /dev/null +++ b/app/components/draft/AuthRecoveryOverlay.tsx @@ -0,0 +1,55 @@ +import { Button } from "~/components/ui/button"; +import { Card } from "~/components/ui/card"; + +interface AuthRecoveryOverlayProps { + isAuthDegraded: boolean; +} + +export function AuthRecoveryOverlay({ + isAuthDegraded, +}: AuthRecoveryOverlayProps) { + if (!isAuthDegraded) { + return null; + } + + return ( +
+ +
+
+ + + +
+ +
+

Session Expired

+

+ Your session has expired. Reload the page to reconnect to the + draft. +

+
+ + +
+
+
+ ); +} diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 7e747ee..90767c5 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -4,7 +4,7 @@ import { eq, and, asc, desc, inArray } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { useDraftSocket } from "~/hooks/useDraftSocket"; -import { useEffect, useState, useMemo, useRef } from "react"; +import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { Card } from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; @@ -20,6 +20,7 @@ import { AvailableParticipantsSection } from "~/components/draft/AvailablePartic import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks"; import { DraftGridSection } from "~/components/draft/DraftGridSection"; import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay"; +import { AuthRecoveryOverlay } from "~/components/draft/AuthRecoveryOverlay"; import { ParticipantSelectionDialog } from "~/components/draft/ParticipantSelectionDialog"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { getTeamForPick } from "~/lib/draft-order"; @@ -230,7 +231,7 @@ export default function DraftRoom() { ownerMap, } = useLoaderData(); const { revalidate, state: revalidatorState } = useRevalidator(); - const { userId: clerkUserId } = useAuth(); + const { userId: clerkUserId, getToken } = useAuth(); const { isConnected, connectionError, isReconnecting, reconnectCount, on, off } = useDraftSocket(season.id, userTeam?.id); const { permissionState: notificationsPermission, @@ -299,6 +300,52 @@ export default function DraftRoom() { } }, [clerkUserId, currentUserId, revalidate]); + // Auth session protection: proactively refresh the Clerk JWT to prevent + // silent logout during long-running draft sessions. + const [authDegraded, setAuthDegraded] = useState(false); + + // Fetch wrapper that detects 401 responses and shows the auth recovery + // overlay instead of letting each handler deal with it individually. + // Returns null on 401 so callers can bail with `if (!response) return;`. + const authFetch = useCallback(async (url: string, init?: RequestInit): Promise => { + const response = await fetch(url, init); + if (response.status === 401) { + setAuthDegraded(true); + return null; + } + return response; + }, []); + + // Refresh the Clerk JWT when the tab becomes visible again. Browsers + // throttle/suspend intervals in background tabs, so the SDK's automatic + // 60-second refresh may not have run. Getting a fresh token here prevents + // the next user action from hitting a 401. + useEffect(() => { + const handleVisibilityChange = async () => { + if (document.visibilityState !== "visible" || !clerkUserId || authDegraded) return; + try { + const token = await getToken({ skipCache: true }); + if (token === null) { + // Retry once after a short delay — the network may still be waking up. + await new Promise((r) => setTimeout(r, 1000)); + const retryToken = await getToken({ skipCache: true }); + if (retryToken === null) setAuthDegraded(true); + } + } catch { + try { + await new Promise((r) => setTimeout(r, 1000)); + const retryToken = await getToken({ skipCache: true }); + if (retryToken === null) setAuthDegraded(true); + } catch { + setAuthDegraded(true); + } + } + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => + document.removeEventListener("visibilitychange", handleVisibilityChange); + }, [clerkUserId, getToken, authDegraded]); + // Track revalidation lifecycle to sync local state from fresh loader data. // // The problem: `picks` and `currentPick` are local state initialized once @@ -778,10 +825,11 @@ export default function DraftRoom() { formData.append("participantId", participantId); try { - const response = await fetch("/api/queue/add", { + const response = await authFetch("/api/queue/add", { method: "POST", body: formData, }); + if (!response) { setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); return; } if (response.ok) { const data = await response.json(); @@ -794,7 +842,6 @@ export default function DraftRoom() { } else { // Revert the optimistic update on error setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); - const error = await response.json(); toast.error(error.error || "Failed to add to queue"); } @@ -820,10 +867,11 @@ export default function DraftRoom() { formData.append("teamId", userTeam.id); try { - const response = await fetch("/api/queue/remove", { + const response = await authFetch("/api/queue/remove", { method: "POST", body: formData, }); + if (!response) { setQueue(previousQueue); return; } if (response.ok) { const data = await response.json(); @@ -857,10 +905,11 @@ export default function DraftRoom() { formData.append("participantIds", JSON.stringify(participantIds)); try { - const response = await fetch("/api/queue/reorder", { + const response = await authFetch("/api/queue/reorder", { method: "POST", body: formData, }); + if (!response) { setQueue(previousQueue); return; } if (response.ok) { const data = await response.json(); @@ -877,19 +926,24 @@ export default function DraftRoom() { }; const handleStartDraft = async () => { - const formData = new FormData(); - formData.append("seasonId", season.id); + try { + const formData = new FormData(); + formData.append("seasonId", season.id); - const response = await fetch("/api/draft/start", { - method: "POST", - body: formData, - }); + const response = await authFetch("/api/draft/start", { + method: "POST", + body: formData, + }); + if (!response) return; - if (response.ok) { - revalidate(); - } else { - const error = await response.json(); - toast.error(error.error || "Failed to start draft"); + if (response.ok) { + revalidate(); + } else { + const error = await response.json(); + toast.error(error.error || "Failed to start draft"); + } + } catch { + toast.error("Network error - failed to start draft"); } }; @@ -898,10 +952,11 @@ export default function DraftRoom() { formData.append("seasonId", season.id); try { - const response = await fetch("/api/draft/pause", { + const response = await authFetch("/api/draft/pause", { method: "POST", body: formData, }); + if (!response) return; if (response.ok) { setIsPaused(true); @@ -919,10 +974,11 @@ export default function DraftRoom() { formData.append("seasonId", season.id); try { - const response = await fetch("/api/draft/resume", { + const response = await authFetch("/api/draft/resume", { method: "POST", body: formData, }); + if (!response) return; if (response.ok) { setIsPaused(false); @@ -936,58 +992,73 @@ export default function DraftRoom() { }; const handleMakePick = async (participantId: string) => { - const formData = new FormData(); - formData.append("seasonId", season.id); - formData.append("participantId", participantId); + try { + const formData = new FormData(); + formData.append("seasonId", season.id); + formData.append("participantId", participantId); - const response = await fetch("/api/draft/make-pick", { - method: "POST", - body: formData, - }); + const response = await authFetch("/api/draft/make-pick", { + method: "POST", + body: formData, + }); + if (!response) return; - if (!response.ok) { - const error = await response.json(); - toast.error(error.error || "Failed to make pick"); + if (!response.ok) { + const error = await response.json(); + toast.error(error.error || "Failed to make pick"); + } + } catch { + toast.error("Network error - failed to make pick"); } }; const handleForceAutopick = async (pickNumber: number, teamId: string) => { - const formData = new FormData(); - formData.append("seasonId", season.id); - formData.append("teamId", teamId); - formData.append("pickNumber", pickNumber.toString()); + try { + const formData = new FormData(); + formData.append("seasonId", season.id); + formData.append("teamId", teamId); + formData.append("pickNumber", pickNumber.toString()); - const response = await fetch("/api/draft/force-autopick", { - method: "POST", - body: formData, - }); + const response = await authFetch("/api/draft/force-autopick", { + method: "POST", + body: formData, + }); + if (!response) return; - if (!response.ok) { - const error = await response.json(); - toast.error(error.error || "Failed to force autopick"); + if (!response.ok) { + const error = await response.json(); + toast.error(error.error || "Failed to force autopick"); + } + } catch { + toast.error("Network error - failed to force autopick"); } }; const handleForceManualPick = async (participantId: string) => { if (!selectedPickSlot) return; - const formData = new FormData(); - formData.append("seasonId", season.id); - formData.append("teamId", selectedPickSlot.teamId); - formData.append("participantId", participantId); - formData.append("pickNumber", selectedPickSlot.pickNumber.toString()); + try { + const formData = new FormData(); + formData.append("seasonId", season.id); + formData.append("teamId", selectedPickSlot.teamId); + formData.append("participantId", participantId); + formData.append("pickNumber", selectedPickSlot.pickNumber.toString()); - const response = await fetch("/api/draft/force-manual-pick", { - method: "POST", - body: formData, - }); + const response = await authFetch("/api/draft/force-manual-pick", { + method: "POST", + body: formData, + }); + if (!response) return; - if (response.ok) { - setForcePickDialogOpen(false); - setSelectedPickSlot(null); - } else { - const error = await response.json(); - toast.error(error.error || "Failed to force manual pick"); + if (response.ok) { + setForcePickDialogOpen(false); + setSelectedPickSlot(null); + } else { + const error = await response.json(); + toast.error(error.error || "Failed to force manual pick"); + } + } catch { + toast.error("Network error - failed to force manual pick"); } }; @@ -1004,23 +1075,28 @@ export default function DraftRoom() { const handleReplacePick = async (participantId: string) => { if (!replacePickSlot) return; - const formData = new FormData(); - formData.append("seasonId", season.id); - formData.append("teamId", replacePickSlot.teamId); - formData.append("participantId", participantId); - formData.append("pickNumber", replacePickSlot.pickNumber.toString()); + try { + const formData = new FormData(); + formData.append("seasonId", season.id); + formData.append("teamId", replacePickSlot.teamId); + formData.append("participantId", participantId); + formData.append("pickNumber", replacePickSlot.pickNumber.toString()); - const response = await fetch("/api/draft/replace-pick", { - method: "POST", - body: formData, - }); + const response = await authFetch("/api/draft/replace-pick", { + method: "POST", + body: formData, + }); + if (!response) return; - if (response.ok) { - setReplacePickDialogOpen(false); - setReplacePickSlot(null); - } else { - const error = await response.json(); - toast.error(error.error || "Failed to replace pick"); + if (response.ok) { + setReplacePickDialogOpen(false); + setReplacePickSlot(null); + } else { + const error = await response.json(); + toast.error(error.error || "Failed to replace pick"); + } + } catch { + toast.error("Network error - failed to replace pick"); } }; @@ -1033,22 +1109,29 @@ export default function DraftRoom() { if (!rollbackPickNumber || isRollingBack) return; setIsRollingBack(true); - const formData = new FormData(); - formData.append("seasonId", season.id); - formData.append("pickNumber", rollbackPickNumber.toString()); + try { + const formData = new FormData(); + formData.append("seasonId", season.id); + formData.append("pickNumber", rollbackPickNumber.toString()); - const response = await fetch("/api/draft/rollback", { - method: "POST", - body: formData, - }); + const response = await authFetch("/api/draft/rollback", { + method: "POST", + body: formData, + }); - setIsRollingBack(false); - if (response.ok) { - setRollbackConfirmOpen(false); - setRollbackPickNumber(null); - } else { - const error = await response.json(); - toast.error(error.error || "Failed to roll back draft"); + setIsRollingBack(false); + if (!response) return; + + if (response.ok) { + setRollbackConfirmOpen(false); + setRollbackPickNumber(null); + } else { + const error = await response.json(); + toast.error(error.error || "Failed to roll back draft"); + } + } catch { + setIsRollingBack(false); + toast.error("Network error - failed to roll back draft"); } }; @@ -1086,10 +1169,11 @@ export default function DraftRoom() { formData.append("teamId", timeBankTeamId); formData.append("adjustment", adjustment.toString()); - const response = await fetch("/api/draft/adjust-time-bank", { + const response = await authFetch("/api/draft/adjust-time-bank", { method: "POST", body: formData, }); + if (!response) return; if (response.ok) { const data = await response.json(); @@ -1756,6 +1840,9 @@ export default function DraftRoom() { isReconnecting={isReconnecting} connectionError={connectionError} /> + + {/* Auth Recovery Overlay - blocks interaction when session expires */} + ); }