Files
originsdigital/frontend/src/pages/AdminPage.tsx
Tetardtek 01d347bce3 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)
2026-03-14 22:37:36 +01:00

562 lines
23 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import { apiFetch } from '../lib/api';
import { useAuthContext } from '../context/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || '/api';
// ── Types ────────────────────────────────────────────────────────────────────
interface Video {
id: string;
title: string;
storageType: string;
storageKey: string;
requiredLevel: number;
isPublished: boolean;
createdAt: string;
}
interface Plan {
id: string;
slug: string;
name: string;
level: number;
priceInCents: number;
isActive: boolean;
}
interface AdminUser {
id: string;
email: string | null;
nickname: string;
isActive: boolean;
createdAt: string;
roles: { id: string; slug: string; name: string }[];
activeSubscription: {
id: string;
status: string;
endsAt: string | null;
plan: Plan;
} | null;
}
// ── Tabs ─────────────────────────────────────────────────────────────────────
type Tab = 'videos' | 'users' | 'plans';
export default function AdminPage() {
const { user, loading: authLoading } = useAuthContext();
const [tab, setTab] = useState<Tab>('videos');
if (authLoading) return null;
if (!user?.roles?.some((r) => r === 'admin' || r === 'super_admin')) return <Navigate to="/" replace />;
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-1 border-b border-od-border pb-4">
{(['videos', 'users', 'plans'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`rounded px-3 py-1.5 font-mono text-xs transition-colors ${
tab === t
? 'bg-od-surface text-od-accent border border-od-accent'
: 'text-od-muted hover:text-od-text'
}`}
>
{t}
</button>
))}
</div>
{tab === 'videos' && <VideosTab />}
{tab === 'users' && <UsersTab />}
{tab === 'plans' && <PlansTab />}
</div>
);
}
// ── Videos tab ───────────────────────────────────────────────────────────────
function VideosTab() {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [form, setForm] = useState({
title: '', storageType: 'youtube', storageKey: '',
requiredLevel: 0, isPublished: false,
});
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<{ success: boolean; data: { videos: Video[] } }>('/admin/videos')
.then((r) => setVideos(r.data.videos))
.catch(() => setFetchError('Impossible de charger les vidéos.'))
.finally(() => setLoading(false));
}, []);
async function handleFileUpload(file: File) {
setUploading(true);
setUploadError(null);
setForm((f) => ({ ...f, storageKey: '' }));
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`${API_BASE}/admin/videos/upload`, {
method: 'POST',
credentials: 'include',
body: fd,
});
if (!res.ok) throw new Error(`${res.status}`);
const r = await res.json() as { success: boolean; data: { storageKey: string; storageType: string } };
setForm((f) => ({ ...f, storageKey: r.data.storageKey, storageType: r.data.storageType }));
} catch {
setUploadError('Échec de l\'upload — vérifier format (mp4/webm, 4 Go max).');
}
setUploading(false);
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!form.title || !form.storageKey || saving) return;
setSaving(true);
setError(null);
try {
const r = await apiFetch<{ success: boolean; data: { video: Video } }>(
'/admin/videos',
{ method: 'POST', body: JSON.stringify(form) }
);
setVideos((v) => [r.data.video, ...v]);
setForm({ title: '', storageType: 'youtube', storageKey: '', requiredLevel: 0, isPublished: false });
} catch {
setError('Erreur lors de la création.');
}
setSaving(false);
}
async function togglePublish(video: Video) {
setActionError(null);
try {
const r = await apiFetch<{ success: boolean; data: { video: Video } }>(
`/admin/videos/${video.id}`,
{ method: 'PATCH', body: JSON.stringify({ isPublished: !video.isPublished }) }
);
setVideos((v) => v.map((x) => x.id === video.id ? r.data.video : x));
} catch {
setActionError('Impossible de modifier la vidéo.');
}
}
async function handleDelete(id: string) {
if (!confirm('Supprimer cette vidéo ?')) return;
setActionError(null);
try {
await apiFetch(`/admin/videos/${id}`, { method: 'DELETE' });
setVideos((v) => v.filter((x) => x.id !== id));
} catch {
setActionError('Impossible de supprimer la vidéo.');
}
}
return (
<div className="flex flex-col gap-6">
{/* Formulaire création */}
<form onSubmit={handleCreate} className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4">
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">Nouvelle vidéo</p>
<input
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Titre"
required
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
<div className="flex gap-2">
<select
value={form.storageType}
onChange={(e) => setForm((f) => ({ ...f, storageType: e.target.value, storageKey: '' }))}
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text outline-none focus:border-od-accent"
>
<option value="youtube">YouTube</option>
<option value="local">Local</option>
<option value="s3">S3</option>
<option value="external">External</option>
</select>
{form.storageType === 'local' ? (
<div className="flex flex-1 flex-col gap-1">
<input
type="file"
accept="video/mp4,video/webm"
disabled={uploading}
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFileUpload(f); }}
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text outline-none focus:border-od-accent file:mr-2 file:rounded file:border-0 file:bg-od-surface file:px-2 file:py-0.5 file:font-mono file:text-xs file:text-od-muted"
/>
{uploading && <p className="font-mono text-xs text-od-muted">Envoi en cours</p>}
{uploadError && <p className="font-mono text-xs text-od-crit">{uploadError}</p>}
{!uploading && !uploadError && form.storageKey && (
<p className="font-mono text-xs text-od-ok"> Upload réussi</p>
)}
</div>
) : (
<input
value={form.storageKey}
onChange={(e) => setForm((f) => ({ ...f, storageKey: e.target.value }))}
placeholder={form.storageType === 'youtube' ? 'ID YouTube (ex: dQw4w9WgXcQ)' : 'Chemin / URL'}
required
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
)}
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-od-muted">
Niveau requis
<input
type="number"
min={0}
value={form.requiredLevel}
onChange={(e) => setForm((f) => ({ ...f, requiredLevel: parseInt(e.target.value) || 0 }))}
className="w-16 rounded border border-od-border bg-od-bg px-2 py-1 text-sm text-od-text outline-none focus:border-od-accent"
/>
</label>
<label className="flex items-center gap-2 text-sm text-od-muted">
<input
type="checkbox"
checked={form.isPublished}
onChange={(e) => setForm((f) => ({ ...f, isPublished: e.target.checked }))}
/>
Publié
</label>
</div>
{error && <p className="text-xs text-od-crit">{error}</p>}
<button
type="submit"
disabled={saving || uploading}
className="self-start rounded border border-od-accent px-4 py-1.5 font-mono text-xs text-od-accent hover:bg-od-accent hover:text-od-bg transition-colors disabled:opacity-40"
>
{saving ? '…' : 'Créer'}
</button>
</form>
{/* Liste */}
{loading ? (
<div className="flex flex-col gap-2">
{[...Array(3)].map((_, i) => <div key={i} className="h-12 rounded border border-od-border animate-pulse" />)}
</div>
) : fetchError ? (
<p className="text-sm text-od-crit">{fetchError}</p>
) : (
<div className="flex flex-col gap-2">
{actionError && <p className="text-xs text-od-crit">{actionError}</p>}
{videos.map((v) => (
<div key={v.id} className="flex items-center gap-3 rounded border border-od-border bg-od-surface px-4 py-3">
<div className="flex-1 min-w-0">
<p className="text-sm text-od-text truncate">{v.title}</p>
<p className="font-mono text-xs text-od-muted">{v.storageType} · niveau {v.requiredLevel}</p>
</div>
<button
onClick={() => togglePublish(v)}
className={`font-mono text-xs px-2 py-0.5 rounded border transition-colors ${
v.isPublished
? 'border-od-accent text-od-accent hover:bg-od-accent hover:text-od-bg'
: 'border-od-border text-od-muted hover:border-od-accent hover:text-od-accent'
}`}
>
{v.isPublished ? 'publié' : 'brouillon'}
</button>
<button
onClick={() => handleDelete(v.id)}
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors"
>
</button>
</div>
))}
{videos.length === 0 && <p className="text-sm text-od-muted">Aucune vidéo.</p>}
</div>
)}
</div>
);
}
// ── Users tab ────────────────────────────────────────────────────────────────
function UsersTab() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [assigning, setAssigning] = useState<string | null>(null);
const [selectedPlan, setSelectedPlan] = useState<Record<string, string>>({});
const [assigningRole, setAssigningRole] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<Record<string, string>>({});
useEffect(() => {
Promise.all([
apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users'),
apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans'),
])
.then(([ur, pr]) => {
setUsers(ur.data.users);
setPlans(pr.data.plans);
})
.catch(() => setError('Impossible de charger les utilisateurs.'))
.finally(() => setLoading(false));
}, []);
async function assignPlan(userId: string) {
const planId = selectedPlan[userId];
if (!planId || assigning) return;
setAssigning(userId);
setActionError(null);
try {
await apiFetch(`/admin/users/${userId}/subscriptions`, {
method: 'POST',
body: JSON.stringify({ planId }),
});
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
setUsers(r.data.users);
} catch {
setActionError('Impossible d\'assigner le plan.');
}
setAssigning(null);
}
async function assignRole(userId: string) {
const role = selectedRole[userId];
if (!role || assigningRole) return;
setAssigningRole(userId);
setActionError(null);
try {
await apiFetch(`/admin/users/${userId}/roles`, {
method: 'PATCH',
body: JSON.stringify({ roles: [role] }),
});
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
setUsers(r.data.users);
} catch {
setActionError('Impossible d\'assigner le rôle.');
}
setAssigningRole(null);
}
if (loading) return (
<div className="flex flex-col gap-2">
{[...Array(3)].map((_, i) => <div key={i} className="h-20 rounded border border-od-border animate-pulse" />)}
</div>
);
if (error) return <p className="text-sm text-od-crit">{error}</p>;
return (
<div className="flex flex-col gap-2">
{actionError && <p className="text-xs text-od-crit">{actionError}</p>}
{users.map((u) => (
<div key={u.id} className="flex flex-col gap-2 rounded border border-od-border bg-od-surface px-4 py-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-od-text">{u.nickname}</p>
<p className="font-mono text-xs text-od-muted">{u.email ?? '—'}</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className="font-mono text-xs text-od-muted">
{u.roles.map((r) => r.slug).join(', ') || '—'}
</span>
{u.activeSubscription ? (
<span className="font-mono text-xs text-od-accent">
{u.activeSubscription.plan.name}
{u.activeSubscription.endsAt && ` · ${new Date(u.activeSubscription.endsAt).toLocaleDateString()}`}
</span>
) : (
<span className="font-mono text-xs text-od-muted">free</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<select
value={selectedPlan[u.id] ?? ''}
onChange={(e) => setSelectedPlan((s) => ({ ...s, [u.id]: e.target.value }))}
className="flex-1 rounded border border-od-border bg-od-bg px-2 py-1 text-xs text-od-text outline-none focus:border-od-accent"
>
<option value=""> Plan </option>
{plans.filter((p) => p.isActive).map((p) => (
<option key={p.id} value={p.id}>{p.name} (niv. {p.level})</option>
))}
</select>
<button
disabled={!selectedPlan[u.id] || assigning === u.id}
onClick={() => assignPlan(u.id)}
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors disabled:opacity-40"
>
{assigning === u.id ? '…' : 'Plan'}
</button>
<select
value={selectedRole[u.id] ?? ''}
onChange={(e) => setSelectedRole((s) => ({ ...s, [u.id]: e.target.value }))}
className="rounded border border-od-border bg-od-bg px-2 py-1 text-xs text-od-text outline-none focus:border-od-accent"
>
<option value=""> Rôle </option>
<option value="user">user</option>
<option value="admin">admin</option>
<option value="super_admin">super_admin</option>
</select>
<button
disabled={!selectedRole[u.id] || assigningRole === u.id}
onClick={() => assignRole(u.id)}
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors disabled:opacity-40"
>
{assigningRole === u.id ? '…' : 'Rôle'}
</button>
</div>
</div>
))}
{users.length === 0 && <p className="text-sm text-od-muted">Aucun utilisateur.</p>}
</div>
);
}
// ── Plans tab ─────────────────────────────────────────────────────────────────
function PlansTab() {
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [form, setForm] = useState({ slug: '', name: '', level: 1, priceInCents: 0 });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans')
.then((r) => setPlans(r.data.plans))
.catch(() => setFetchError('Impossible de charger les plans.'))
.finally(() => setLoading(false));
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!form.slug || !form.name || saving) return;
setSaving(true);
setError(null);
try {
const r = await apiFetch<{ success: boolean; data: { plan: Plan } }>(
'/admin/plans',
{ method: 'POST', body: JSON.stringify(form) }
);
setPlans((p) => [...p, r.data.plan]);
setForm({ slug: '', name: '', level: 1, priceInCents: 0 });
} catch {
setError('Erreur lors de la création.');
}
setSaving(false);
}
async function toggleActive(plan: Plan) {
setActionError(null);
try {
const r = await apiFetch<{ success: boolean; data: { plan: Plan } }>(
`/admin/plans/${plan.id}`,
{ method: 'PATCH', body: JSON.stringify({ isActive: !plan.isActive }) }
);
setPlans((p) => p.map((x) => x.id === plan.id ? r.data.plan : x));
} catch {
setActionError('Impossible de modifier le plan.');
}
}
if (loading) return (
<div className="flex flex-col gap-2">
{[...Array(2)].map((_, i) => <div key={i} className="h-12 rounded border border-od-border animate-pulse" />)}
</div>
);
if (fetchError) return <p className="text-sm text-od-crit">{fetchError}</p>;
return (
<div className="flex flex-col gap-6">
<form onSubmit={handleCreate} className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4">
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">Nouveau plan</p>
<div className="flex gap-2">
<input
value={form.slug}
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
placeholder="slug (ex: premium)"
required
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="Nom"
required
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
</div>
<div className="flex gap-2">
<label className="flex items-center gap-2 text-sm text-od-muted">
Niveau
<input
type="number" min={1}
value={form.level}
onChange={(e) => setForm((f) => ({ ...f, level: parseInt(e.target.value) || 1 }))}
className="w-16 rounded border border-od-border bg-od-bg px-2 py-1 text-sm text-od-text outline-none focus:border-od-accent"
/>
</label>
<label className="flex items-center gap-2 text-sm text-od-muted">
Prix (centimes)
<input
type="number" min={0}
value={form.priceInCents}
onChange={(e) => setForm((f) => ({ ...f, priceInCents: parseInt(e.target.value) || 0 }))}
className="w-24 rounded border border-od-border bg-od-bg px-2 py-1 text-sm text-od-text outline-none focus:border-od-accent"
/>
</label>
</div>
{error && <p className="text-xs text-od-crit">{error}</p>}
<button
type="submit" disabled={saving}
className="self-start rounded border border-od-accent px-4 py-1.5 font-mono text-xs text-od-accent hover:bg-od-accent hover:text-od-bg transition-colors disabled:opacity-40"
>
{saving ? '…' : 'Créer'}
</button>
</form>
<div className="flex flex-col gap-2">
{actionError && <p className="text-xs text-od-crit">{actionError}</p>}
{plans.map((p) => (
<div key={p.id} className="flex items-center gap-3 rounded border border-od-border bg-od-surface px-4 py-3">
<div className="flex-1">
<p className="text-sm text-od-text">{p.name}</p>
<p className="font-mono text-xs text-od-muted">
{p.slug} · niv. {p.level} · {(p.priceInCents / 100).toFixed(2)}
</p>
</div>
<button
onClick={() => toggleActive(p)}
className={`font-mono text-xs px-2 py-0.5 rounded border transition-colors ${
p.isActive
? 'border-od-accent text-od-accent hover:bg-od-accent hover:text-od-bg'
: 'border-od-border text-od-muted hover:border-od-accent hover:text-od-accent'
}`}
>
{p.isActive ? 'actif' : 'inactif'}
</button>
</div>
))}
{plans.length === 0 && <p className="text-sm text-od-muted">Aucun plan.</p>}
</div>
</div>
);
}