From 2188fa458d4e7230f6c98a4c53a9c35380499b1b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 11:56:56 +0000 Subject: [PATCH] fix: recover gracefully from stale sessions causing repeated 401s The SPA's session check (/api/auth/session) is cached indefinitely by react-query (staleTime: Infinity, no refetch on focus/interval), so once a protected page mounts successfully, the app never re-verifies auth. If the server-side session later becomes invalid (server restart with in-memory sessions, cookie/session expiry, etc.), every subsequent API call (pages, scheduled-posts, audio-tracks, media, media/upload, ...) starts failing with 401 in a loop with no way for the user to recover short of a manual page reload. Add a shared handleUnauthorized() in queryClient.ts that redirects to /login on any non-auth API 401, wired into both the shared fetch helpers (apiRequest/getQueryFn) and the raw fetch() calls used for file uploads and Remotion rendering, which also lacked this recovery path. Also add the missing credentials: "include" to those raw fetch() calls for consistency with the rest of the app's API requests. --- client/src/components/media-upload.tsx | 14 ++++++++-- client/src/lib/queryClient.ts | 32 +++++++++++++++++++--- client/src/pages/audio-admin.tsx | 5 ++++ client/src/pages/mobile/new-post.tsx | 8 ++++-- client/src/pages/mobile/new-reel.tsx | 4 ++- client/src/pages/mobile/remotion-video.tsx | 13 +++++---- client/src/pages/new-post.tsx | 8 ++++-- client/src/pages/new-reel.tsx | 8 ++++-- client/src/pages/remotion-video.tsx | 13 +++++---- 9 files changed, 81 insertions(+), 24 deletions(-) diff --git a/client/src/components/media-upload.tsx b/client/src/components/media-upload.tsx index dd48233..5eac0af 100644 --- a/client/src/components/media-upload.tsx +++ b/client/src/components/media-upload.tsx @@ -3,7 +3,7 @@ import { useDropzone } from "react-dropzone"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { apiRequest, queryClient } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { MediaThumbnail } from "@/components/media-thumbnail"; import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; @@ -30,8 +30,12 @@ export default function MediaUpload() { const response = await fetch("/api/media/upload", { method: "POST", body: formData, + credentials: "include", }); - if (!response.ok) throw new Error("Upload failed"); + if (!response.ok) { + if (response.status === 401) handleUnauthorized("/api/media/upload"); + throw new Error("Upload failed"); + } return response.json(); }, onSuccess: (data) => { @@ -55,8 +59,12 @@ export default function MediaUpload() { const response = await fetch("/api/media/upload", { method: "POST", body: formData, + credentials: "include", }); - if (!response.ok) throw new Error("Upload failed"); + if (!response.ok) { + if (response.status === 401) handleUnauthorized("/api/media/upload"); + throw new Error("Upload failed"); + } successCount++; return response.json(); } catch (error) { diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index f315a14..56f2fae 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -1,7 +1,30 @@ import { QueryClient, QueryFunction } from "@tanstack/react-query"; -async function throwIfResNotOk(res: Response) { +// Les routes d'authentification elles-mêmes ne doivent jamais déclencher +// de redirection automatique (ex: mauvais mot de passe sur /login). +function isAuthRoute(url: string): boolean { + return url.startsWith("/api/auth/"); +} + +// Si la session serveur a expiré (ex: redémarrage du serveur, expiration du +// cookie) alors que la SPA pense toujours l'utilisateur connecté (le check +// de session initial est mis en cache indéfiniment par react-query), toutes +// les requêtes suivantes échouent silencieusement en 401 sans jamais +// renvoyer l'utilisateur vers l'écran de connexion. On force donc un +// rechargement vers /login dès qu'une requête API (hors routes d'auth) +// renvoie 401, ce qui réinitialise aussi le cache react-query. +export function handleUnauthorized(url: string) { + if (isAuthRoute(url) || window.location.pathname === "/login") { + return; + } + window.location.href = "/login"; +} + +async function throwIfResNotOk(res: Response, url: string) { if (!res.ok) { + if (res.status === 401) { + handleUnauthorized(url); + } const text = (await res.text()) || res.statusText; throw new Error(`${res.status}: ${text}`); } @@ -19,7 +42,7 @@ export async function apiRequest( credentials: "include", }); - await throwIfResNotOk(res); + await throwIfResNotOk(res, url); return res; } @@ -29,7 +52,8 @@ export const getQueryFn: (options: { }) => QueryFunction = ({ on401: unauthorizedBehavior }) => async ({ queryKey }) => { - const res = await fetch(queryKey.join("/") as string, { + const url = queryKey.join("/") as string; + const res = await fetch(url, { credentials: "include", }); @@ -37,7 +61,7 @@ export const getQueryFn: (options: { return null; } - await throwIfResNotOk(res); + await throwIfResNotOk(res, url); return await res.json(); }; diff --git a/client/src/pages/audio-admin.tsx b/client/src/pages/audio-admin.tsx index 7397f3f..8b8bcb0 100644 --- a/client/src/pages/audio-admin.tsx +++ b/client/src/pages/audio-admin.tsx @@ -8,6 +8,7 @@ import { useToast } from "@/hooks/use-toast"; import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react"; import { format } from "date-fns"; import { fr } from "date-fns/locale"; +import { handleUnauthorized } from "@/lib/queryClient"; interface AudioTrack { id: number; @@ -41,9 +42,11 @@ export default function AudioAdmin() { const res = await fetch("/api/audio-tracks", { method: "POST", body: formData, + credentials: "include", }); if (!res.ok) { + if (res.status === 401) handleUnauthorized("/api/audio-tracks"); const error = await res.json(); throw new Error(error.error || "Erreur lors de l'upload"); } @@ -77,9 +80,11 @@ export default function AudioAdmin() { mutationFn: async (id: number) => { const res = await fetch(`/api/audio-tracks/${id}`, { method: "DELETE", + credentials: "include", }); if (!res.ok) { + if (res.status === 401) handleUnauthorized(`/api/audio-tracks/${id}`); throw new Error("Erreur lors de la suppression"); } }, diff --git a/client/src/pages/mobile/new-post.tsx b/client/src/pages/mobile/new-post.tsx index 82aab1f..d463e79 100644 --- a/client/src/pages/mobile/new-post.tsx +++ b/client/src/pages/mobile/new-post.tsx @@ -32,7 +32,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import { PreviewModal } from "@/components/preview-modal"; import { DateTimePicker } from "@/components/datetime-picker"; @@ -152,8 +152,12 @@ export default function NewPostMobile() { const response = await fetch("/api/media/upload", { method: "POST", body: formData, + credentials: "include", }); - if (!response.ok) throw new Error("Upload failed"); + if (!response.ok) { + if (response.status === 401) handleUnauthorized("/api/media/upload"); + throw new Error("Upload failed"); + } return response.json(); }, onSuccess: (data: Media) => { diff --git a/client/src/pages/mobile/new-reel.tsx b/client/src/pages/mobile/new-reel.tsx index 87017e3..ba86893 100644 --- a/client/src/pages/mobile/new-reel.tsx +++ b/client/src/pages/mobile/new-reel.tsx @@ -16,7 +16,7 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Slider } from "@/components/ui/slider"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import type { SocialPage, Media } from "@shared/schema"; import { DateTimePicker } from "@/components/datetime-picker"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; @@ -145,8 +145,10 @@ export default function MobileNewReel() { const response = await fetch("/api/media/upload", { method: "POST", body: formData, + credentials: "include", }); if (!response.ok) { + if (response.status === 401) handleUnauthorized("/api/media/upload"); const errorText = await response.text(); throw new Error(`Upload failed: ${response.status} ${response.statusText} - ${errorText.substring(0, 100)}`); } diff --git a/client/src/pages/mobile/remotion-video.tsx b/client/src/pages/mobile/remotion-video.tsx index 7986770..0a508b3 100644 --- a/client/src/pages/mobile/remotion-video.tsx +++ b/client/src/pages/mobile/remotion-video.tsx @@ -13,7 +13,7 @@ import Sidebar from "@/components/sidebar"; import { Checkbox } from "@/components/ui/checkbox"; import { DateTimePicker } from "@/components/datetime-picker"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { apiRequest, queryClient } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import type { Media, SocialPage } from "@shared/schema"; interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; } @@ -152,8 +152,8 @@ export default function MobileRemotionVideoPage() { if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]); if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); } - const response = await fetch("/api/remotion/render", { method: "POST", body: formData }); - if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } + const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" }); + if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } const data = await response.json(); if (data.jobId) { @@ -161,8 +161,11 @@ export default function MobileRemotionVideoPage() { const checkStatus = async () => { try { - const res = await fetch(`/api/remotion/render/status/${data.jobId}`); - if (!res.ok) throw new Error("Erreur de suivi du rendu"); + const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" }); + if (!res.ok) { + if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`); + throw new Error("Erreur de suivi du rendu"); + } const job = await res.json(); if (job.status === 'done') { diff --git a/client/src/pages/new-post.tsx b/client/src/pages/new-post.tsx index 48c97d8..10f2ca3 100644 --- a/client/src/pages/new-post.tsx +++ b/client/src/pages/new-post.tsx @@ -31,7 +31,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { MediaThumbnail } from "@/components/media-thumbnail"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import { PreviewModal } from "@/components/preview-modal"; @@ -148,8 +148,12 @@ export default function NewPost() { const response = await fetch("/api/media/upload", { method: "POST", body: formData, + credentials: "include", }); - if (!response.ok) throw new Error("Upload failed"); + if (!response.ok) { + if (response.status === 401) handleUnauthorized("/api/media/upload"); + throw new Error("Upload failed"); + } return response.json(); }, onSuccess: (data) => { diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index 9826365..f389fe8 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -25,7 +25,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import type { SocialPage, Media } from "@shared/schema"; import { MediaThumbnail } from "@/components/media-thumbnail"; import { DateTimePicker } from "@/components/datetime-picker"; @@ -166,8 +166,12 @@ export default function NewReel() { const response = await fetch("/api/media/upload", { method: "POST", body: formData, + credentials: "include", }); - if (!response.ok) throw new Error("Upload failed"); + if (!response.ok) { + if (response.status === 401) handleUnauthorized("/api/media/upload"); + throw new Error("Upload failed"); + } return response.json(); }, onSuccess: (data) => { diff --git a/client/src/pages/remotion-video.tsx b/client/src/pages/remotion-video.tsx index 523e8b6..0ca00ce 100644 --- a/client/src/pages/remotion-video.tsx +++ b/client/src/pages/remotion-video.tsx @@ -8,7 +8,7 @@ import { Textarea } from "@/components/ui/textarea"; import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react"; import { Slider } from "@/components/ui/slider"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { apiRequest, queryClient } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Checkbox } from "@/components/ui/checkbox"; @@ -119,8 +119,8 @@ export default function RemotionVideoPage() { if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]); if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); } - const response = await fetch("/api/remotion/render", { method: "POST", body: formData }); - if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } + const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" }); + if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } const data = await response.json(); if (data.jobId) { @@ -128,8 +128,11 @@ export default function RemotionVideoPage() { const checkStatus = async () => { try { - const res = await fetch(`/api/remotion/render/status/${data.jobId}`); - if (!res.ok) throw new Error("Erreur de suivi du rendu"); + const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" }); + if (!res.ok) { + if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`); + throw new Error("Erreur de suivi du rendu"); + } const job = await res.json(); if (job.status === 'done') {