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:
@@ -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>;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user