Ne plus servir un index.html périmé, et nommer les sessions expirées
Build & Deploy / build (push) Successful in 23s

nginx ne posait aucun Cache-Control sur index.html. Le navigateur appliquait
son cache heuristique et gardait l'ancien index.html, qui référence les anciens
assets fingerprintés — eux marqués immutable un an. Conséquence : un
déploiement pouvait rester invisible pendant une durée indéterminée, sans
Ctrl+Shift+R. no-cache ne désactive pas le cache, il impose une revalidation :
304 tant que rien ne change, nouveau build dès qu'il change.

Côté page admin, un 401 (jeton absent ou expiré au bout de 30 min) produisait
« Impossible de charger les utilisateurs » — un message qui accuse le serveur
alors que la session est simplement morte, l'interface continuant de croire
l'utilisateur connecté. Les 401 et 403 sont désormais distingués et nommés.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 16:44:17 +02:00
co-authored by Claude Opus 5
parent 02d56d90bc
commit d5703c6442
2 changed files with 30 additions and 8 deletions
+22 -8
View File
@@ -9,6 +9,20 @@ type Document = { id: string; title: string; source: 'CIR' | 'SCCS'; ingredient?
type ScraperLog = { id: string; when: string; source: string; status: 'pending' | 'running' | 'success' | 'error' }
type Stats = { total_documents?: number; cir_documents?: number; sccs_documents?: number; total_users?: number }
// Un jeton absent ou expiré (30 min de validité) renvoie 401. Le message
// générique laissait croire à une panne du serveur alors qu'il suffit de se
// reconnecter — l'interface, elle, croit toujours la session ouverte.
function assertOk(res: Response, fallback: string){
if(res.ok) return
if(res.status === 401){
throw new Error('Session expirée — déconnecte-toi puis reconnecte-toi.')
}
if(res.status === 403){
throw new Error('Accès refusé : ce compte n\'est pas administrateur.')
}
throw new Error(fallback)
}
export default function Admin() {
const [tab, setTab] = useState<'users'|'documents'|'stats'|'scrapers'>('users')
@@ -38,23 +52,23 @@ export default function Admin() {
try{
if(tab==='users'){
const res = await fetch(`${API_URL}/admin/users?token=${token}`)
if(!res.ok) throw new Error('Impossible de charger les utilisateurs')
assertOk(res, 'Impossible de charger les utilisateurs')
const data = await res.json()
setUsers(data || [])
} else if(tab==='documents'){
const res = await fetch(`${API_URL}/documents`)
if(!res.ok) throw new Error('Impossible de charger les documents')
assertOk(res, 'Impossible de charger les documents')
const data = await res.json()
setDocs(data || [])
} else if(tab==='stats'){
const res = await fetch(`${API_URL}/admin/stats?token=${token}`)
if(!res.ok) throw new Error('Impossible de charger les statistiques')
assertOk(res, 'Impossible de charger les statistiques')
const data = await res.json()
setStats(data)
} else if(tab==='scrapers'){
// logs endpoint (fallback)
const res = await fetch(`${API_URL}/admin/scrapers/logs?token=${token}`)
if(!res.ok) throw new Error('Impossible de charger les logs de scrapers')
assertOk(res, 'Impossible de charger les logs de scrapers')
const data = await res.json()
setLogs(data || [])
}
@@ -77,7 +91,7 @@ export default function Admin() {
try{
setLoading(true)
const res = await fetch(`${API_URL}/admin/users/${id}?token=${token}`, { method: 'DELETE' })
if(!res.ok) throw new Error('Suppression impossible')
assertOk(res, 'Suppression impossible')
setUsers(u=>u.filter(x=>x.id!==id))
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: 'Utilisateur supprimé', type: 'success' } }))
}catch(e: unknown){
@@ -92,7 +106,7 @@ export default function Admin() {
setLoading(true)
const newRole = u.role === 'admin' ? 'user' : 'admin'
const res = await fetch(`${API_URL}/admin/users/${u.id}/role?token=${token}`, { method: 'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ role: newRole }) })
if(!res.ok) throw new Error('Impossible de modifier le rôle')
assertOk(res, 'Impossible de modifier le rôle')
setUsers(list=>list.map(x=> x.id===u.id ? {...x, role:newRole} : x))
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: `Rôle mis à jour: ${newRole}`, type: 'success' } }))
}catch(e: unknown){ const msg = e instanceof Error ? e.message : String(e); setError(msg||'Erreur'); window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: msg||'Erreur', type: 'error' } })) }finally{ setLoading(false) }
@@ -102,7 +116,7 @@ export default function Admin() {
try{
setLoading(true)
const res = await fetch(`${API_URL}/admin/documents/${id}?token=${token}`, { method: 'DELETE' })
if(!res.ok) throw new Error('Suppression impossible')
assertOk(res, 'Suppression impossible')
setDocs(d=>d.filter(x=>x.id!==id))
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: 'Document supprimé', type: 'success' } }))
}catch(e: unknown){ const msg = e instanceof Error ? e.message : String(e); setError(msg||'Erreur'); window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: msg||'Erreur', type: 'error' } })) }finally{ setLoading(false) }
@@ -112,7 +126,7 @@ export default function Admin() {
try{
setLoading(true)
const res = await fetch(`${API_URL}/admin/scrapers/run?source=${source}&token=${token}`, { method: 'POST' })
if(!res.ok) throw new Error('Erreur lors du lancement')
assertOk(res, 'Erreur lors du lancement')
const entry = await res.json()
setLogs(l=>[entry, ...l].slice(0,5))
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: `Scraper ${source} lancé`, type: 'success' } }))