fix: ApiError typée + error handling pages video/playlists/admin

- api.ts : ApiError class (status: number) — remplace Error générique
- VideoPage/PlaylistPage : instanceof ApiError au lieu de message.includes()
- PlaylistsPage : fetchError + createError — silent catch supprimé
- AdminPage : guard roles.some() aligné Header (super_admin inclus)
This commit is contained in:
2026-03-14 22:37:36 +01:00
parent 4e8c1aa849
commit 01d347bce3
5 changed files with 29 additions and 11 deletions

View File

@@ -2,6 +2,12 @@
// En prod : VITE_API_URL=https://origins.tetardtek.com/api // En prod : VITE_API_URL=https://origins.tetardtek.com/api
const BASE = import.meta.env.VITE_API_URL || '/api'; const BASE = import.meta.env.VITE_API_URL || '/api';
export class ApiError extends Error {
constructor(public readonly status: number, path: string) {
super(`API ${status}: ${path}`);
}
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {
credentials: 'include', // transmet le cookie httpOnly automatiquement credentials: 'include', // transmet le cookie httpOnly automatiquement
@@ -13,7 +19,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`API ${res.status}: ${path}`); throw new ApiError(res.status, path);
} }
return res.json() as Promise<T>; return res.json() as Promise<T>;

View File

@@ -50,7 +50,7 @@ export default function AdminPage() {
const [tab, setTab] = useState<Tab>('videos'); const [tab, setTab] = useState<Tab>('videos');
if (authLoading) return null; if (authLoading) return null;
if (!user?.roles?.includes('admin')) return <Navigate to="/" replace />; if (!user?.roles?.some((r) => r === 'admin' || r === 'super_admin')) return <Navigate to="/" replace />;
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { apiFetch } from '../lib/api'; import { apiFetch, ApiError } from '../lib/api';
interface Video { interface Video {
id: string; id: string;
@@ -35,8 +35,8 @@ export default function PlaylistPage() {
if (!id) return; if (!id) return;
apiFetch<PlaylistResponse>(`/playlists/${id}`) apiFetch<PlaylistResponse>(`/playlists/${id}`)
.then((res) => setData(res.data)) .then((res) => setData(res.data))
.catch((err: Error) => { .catch((err: unknown) => {
if (err.message.includes('403')) setError('forbidden'); if (err instanceof ApiError && err.status === 403) setError('forbidden');
else setError('not_found'); else setError('not_found');
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));

View File

@@ -21,8 +21,10 @@ export default function PlaylistsPage() {
const [owned, setOwned] = useState<Playlist[]>([]); const [owned, setOwned] = useState<Playlist[]>([]);
const [shared, setShared] = useState<(Playlist & { permission: 'view' | 'edit' })[]>([]); const [shared, setShared] = useState<(Playlist & { permission: 'view' | 'edit' })[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [createTitle, setCreateTitle] = useState(''); const [createTitle, setCreateTitle] = useState('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
apiFetch<PlaylistsResponse>('/playlists') apiFetch<PlaylistsResponse>('/playlists')
@@ -30,7 +32,7 @@ export default function PlaylistsPage() {
setOwned(res.data.owned); setOwned(res.data.owned);
setShared(res.data.shared); setShared(res.data.shared);
}) })
.catch(() => {}) .catch(() => setFetchError('Impossible de charger les playlists.'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -38,6 +40,7 @@ export default function PlaylistsPage() {
e.preventDefault(); e.preventDefault();
if (!createTitle.trim() || creating) return; if (!createTitle.trim() || creating) return;
setCreating(true); setCreating(true);
setCreateError(null);
try { try {
const res = await apiFetch<{ success: boolean; data: { playlist: Playlist } }>( const res = await apiFetch<{ success: boolean; data: { playlist: Playlist } }>(
'/playlists', '/playlists',
@@ -45,7 +48,9 @@ export default function PlaylistsPage() {
); );
setOwned((prev) => [res.data.playlist, ...prev]); setOwned((prev) => [res.data.playlist, ...prev]);
setCreateTitle(''); setCreateTitle('');
} catch {} } catch {
setCreateError('Impossible de créer la playlist.');
}
setCreating(false); setCreating(false);
} }
@@ -59,6 +64,10 @@ export default function PlaylistsPage() {
); );
} }
if (fetchError) {
return <p className="text-sm text-od-crit">{fetchError}</p>;
}
return ( return (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
@@ -67,6 +76,7 @@ export default function PlaylistsPage() {
</section> </section>
{/* Créer */} {/* Créer */}
<div className="flex flex-col gap-1">
<form onSubmit={handleCreate} className="flex gap-2"> <form onSubmit={handleCreate} className="flex gap-2">
<input <input
type="text" type="text"
@@ -83,6 +93,8 @@ export default function PlaylistsPage() {
+ +
</button> </button>
</form> </form>
{createError && <p className="font-mono text-xs text-od-crit">{createError}</p>}
</div>
{/* Mes playlists */} {/* Mes playlists */}
{owned.length > 0 && ( {owned.length > 0 && (

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { apiFetch } from '../lib/api'; import { apiFetch, ApiError } from '../lib/api';
import VideoPlayer from '../components/VideoPlayer'; import VideoPlayer from '../components/VideoPlayer';
interface Video { interface Video {
@@ -30,9 +30,9 @@ export default function VideoPage() {
if (!id) return; if (!id) return;
apiFetch<VideoResponse>(`/videos/${id}`) apiFetch<VideoResponse>(`/videos/${id}`)
.then((res) => setVideo(res.data.video)) .then((res) => setVideo(res.data.video))
.catch((err: Error) => { .catch((err: unknown) => {
if (err.message.includes('403')) setError('forbidden'); if (err instanceof ApiError && err.status === 403) setError('forbidden');
else if (err.message.includes('404')) setError('not_found'); else if (err instanceof ApiError && err.status === 404) setError('not_found');
else setError('unknown'); else setError('unknown');
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));