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:
parent
6c480d47a0
commit
5f23885097
2 changed files with 227 additions and 85 deletions
55
app/components/draft/AuthRecoveryOverlay.tsx
Normal file
55
app/components/draft/AuthRecoveryOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue