fix: prevent silent logout during long-running draft sessions (#49)

Clerk's short-lived JWTs (60s) can expire when browser tabs are
backgrounded and the SDK's refresh interval is throttled. This left
users silently logged out — actions returned 401s shown as generic
error toasts with no recovery path.

Prevention: refresh the Clerk JWT on tab visibility change so the
token is always fresh when the user returns from a backgrounded tab.

Detection: replace all 12 raw fetch() calls with an authFetch wrapper
that catches 401 responses and sets an authDegraded state, triggering
a blocking recovery overlay instead of confusing toasts.

Recovery: AuthRecoveryOverlay (mirrors ConnectionOverlay pattern)
prompts the user to reload the page, which re-authenticates via
Clerk's long-lived session cookie.

Also adds missing try/catch to handleStartDraft, handleMakePick,
handleForceAutopick, handleForceManualPick, and handleReplacePick
which previously had no network error handling.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-01 19:33:03 -08:00 committed by GitHub
parent 6c480d47a0
commit 5f23885097
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 227 additions and 85 deletions

View file

@ -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 (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[100]">
<Card className="w-full max-w-md mx-4 p-8 text-center">
<div className="flex flex-col items-center gap-6">
<div className="w-16 h-16 rounded-full bg-destructive/15 flex items-center justify-center">
<svg
className="w-8 h-8 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<div>
<h2 className="text-2xl font-bold mb-2">Session Expired</h2>
<p className="text-muted-foreground">
Your session has expired. Reload the page to reconnect to the
draft.
</p>
</div>
<Button
onClick={() => window.location.reload()}
className="w-full"
variant="default"
>
Reload Page
</Button>
</div>
</Card>
</div>
);
}

View file

@ -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<typeof loader>();
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<Response | null> => {
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 */}
<AuthRecoveryOverlay isAuthDegraded={authDegraded} />
</div>
);
}