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 */}
+
);
}