Compare commits

...
Author SHA1 Message Date
Claude 2188fa458d 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.
2026-07-27 11:56:56 +00:00
9 changed files with 81 additions and 24 deletions

No files matched your search

+11 -3
View File
@@ -3,7 +3,7 @@ import { useDropzone } from "react-dropzone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useMutation, useQuery } from "@tanstack/react-query"; 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 { MediaThumbnail } from "@/components/media-thumbnail";
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react"; import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -30,8 +30,12 @@ export default function MediaUpload() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, 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(); return response.json();
}, },
onSuccess: (data) => { onSuccess: (data) => {
@@ -55,8 +59,12 @@ export default function MediaUpload() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, 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++; successCount++;
return response.json(); return response.json();
} catch (error) { } catch (error) {
+28 -4
View File
@@ -1,7 +1,30 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query"; 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.ok) {
if (res.status === 401) {
handleUnauthorized(url);
}
const text = (await res.text()) || res.statusText; const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`); throw new Error(`${res.status}: ${text}`);
} }
@@ -19,7 +42,7 @@ export async function apiRequest(
credentials: "include", credentials: "include",
}); });
await throwIfResNotOk(res); await throwIfResNotOk(res, url);
return res; return res;
} }
@@ -29,7 +52,8 @@ export const getQueryFn: <T>(options: {
}) => QueryFunction<T> = }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) => ({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => { async ({ queryKey }) => {
const res = await fetch(queryKey.join("/") as string, { const url = queryKey.join("/") as string;
const res = await fetch(url, {
credentials: "include", credentials: "include",
}); });
@@ -37,7 +61,7 @@ export const getQueryFn: <T>(options: {
return null; return null;
} }
await throwIfResNotOk(res); await throwIfResNotOk(res, url);
return await res.json(); return await res.json();
}; };
+5
View File
@@ -8,6 +8,7 @@ import { useToast } from "@/hooks/use-toast";
import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react"; import { Trash2, Music, Upload, Loader2, Play, Pause } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { handleUnauthorized } from "@/lib/queryClient";
interface AudioTrack { interface AudioTrack {
id: number; id: number;
@@ -41,9 +42,11 @@ export default function AudioAdmin() {
const res = await fetch("/api/audio-tracks", { const res = await fetch("/api/audio-tracks", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 401) handleUnauthorized("/api/audio-tracks");
const error = await res.json(); const error = await res.json();
throw new Error(error.error || "Erreur lors de l'upload"); throw new Error(error.error || "Erreur lors de l'upload");
} }
@@ -77,9 +80,11 @@ export default function AudioAdmin() {
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const res = await fetch(`/api/audio-tracks/${id}`, { const res = await fetch(`/api/audio-tracks/${id}`, {
method: "DELETE", method: "DELETE",
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 401) handleUnauthorized(`/api/audio-tracks/${id}`);
throw new Error("Erreur lors de la suppression"); throw new Error("Erreur lors de la suppression");
} }
}, },
+6 -2
View File
@@ -32,7 +32,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; 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 type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
@@ -152,8 +152,12 @@ export default function NewPostMobile() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, 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(); return response.json();
}, },
onSuccess: (data: Media) => { onSuccess: (data: Media) => {
+3 -1
View File
@@ -16,7 +16,7 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast"; 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 type { SocialPage, Media } from "@shared/schema";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
@@ -145,8 +145,10 @@ export default function MobileNewReel() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (!response.ok) { if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`Upload failed: ${response.status} ${response.statusText} - ${errorText.substring(0, 100)}`); throw new Error(`Upload failed: ${response.status} ${response.statusText} - ${errorText.substring(0, 100)}`);
} }
+8 -5
View File
@@ -13,7 +13,7 @@ import Sidebar from "@/components/sidebar";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
import { useQuery, useMutation } from "@tanstack/react-query"; 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"; import type { Media, SocialPage } from "@shared/schema";
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; } 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 (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); 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 }); const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } 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(); const data = await response.json();
if (data.jobId) { if (data.jobId) {
@@ -161,8 +161,11 @@ export default function MobileRemotionVideoPage() {
const checkStatus = async () => { const checkStatus = async () => {
try { try {
const res = await fetch(`/api/remotion/render/status/${data.jobId}`); const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
if (!res.ok) throw new Error("Erreur de suivi du rendu"); 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(); const job = await res.json();
if (job.status === 'done') { if (job.status === 'done') {
+6 -2
View File
@@ -31,7 +31,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; 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 { MediaThumbnail } from "@/components/media-thumbnail";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
@@ -148,8 +148,12 @@ export default function NewPost() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, 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(); return response.json();
}, },
onSuccess: (data) => { onSuccess: (data) => {
+6 -2
View File
@@ -25,7 +25,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; 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 type { SocialPage, Media } from "@shared/schema";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
@@ -166,8 +166,12 @@ export default function NewReel() {
const response = await fetch("/api/media/upload", { const response = await fetch("/api/media/upload", {
method: "POST", method: "POST",
body: formData, 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(); return response.json();
}, },
onSuccess: (data) => { onSuccess: (data) => {
+8 -5
View File
@@ -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 { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { useQuery, useMutation } from "@tanstack/react-query"; 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 { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -119,8 +119,8 @@ export default function RemotionVideoPage() {
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]); if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); } if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); 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 }); const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
if (!response.ok) { const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } 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(); const data = await response.json();
if (data.jobId) { if (data.jobId) {
@@ -128,8 +128,11 @@ export default function RemotionVideoPage() {
const checkStatus = async () => { const checkStatus = async () => {
try { try {
const res = await fetch(`/api/remotion/render/status/${data.jobId}`); const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
if (!res.ok) throw new Error("Erreur de suivi du rendu"); 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(); const job = await res.json();
if (job.status === 'done') { if (job.status === 'done') {