feat: admin/superadmin — fix response shape, ban/unban, stats tab, role restriction
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 24s

This commit is contained in:
2026-03-15 02:30:11 +01:00
parent d69281a2e0
commit 61d8a5257d
2 changed files with 168 additions and 17 deletions

View File

@@ -41,21 +41,32 @@ interface AdminUser {
} | null;
}
interface Stats {
totalUsers: number;
totalVideos: number;
activeSubscriptions: number;
}
// ── Tabs ─────────────────────────────────────────────────────────────────────
type Tab = 'videos' | 'users' | 'plans';
type Tab = 'videos' | 'users' | 'plans' | 'system';
export default function AdminPage() {
const { user, loading: authLoading } = useAuthContext();
const isSuperAdmin = user?.roles?.includes('super_admin') ?? false;
const [tab, setTab] = useState<Tab>('videos');
if (authLoading) return null;
if (!user?.roles?.some((r) => r === 'admin' || r === 'super_admin')) return <Navigate to="/" replace />;
const tabs: Tab[] = isSuperAdmin
? ['videos', 'users', 'plans', 'system']
: ['videos', 'users', 'plans'];
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) => (
{tabs.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
@@ -71,8 +82,9 @@ export default function AdminPage() {
</div>
{tab === 'videos' && <VideosTab />}
{tab === 'users' && <UsersTab />}
{tab === 'users' && <UsersTab isSuperAdmin={isSuperAdmin} />}
{tab === 'plans' && <PlansTab />}
{tab === 'system' && isSuperAdmin && <SystemTab />}
</div>
);
}
@@ -166,7 +178,6 @@ function VideosTab() {
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>
@@ -219,8 +230,7 @@ function VideosTab() {
<label className="flex items-center gap-2 text-sm text-od-muted">
Niveau requis
<input
type="number"
min={0}
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"
@@ -246,7 +256,6 @@ function VideosTab() {
</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" />)}
@@ -289,7 +298,7 @@ function VideosTab() {
// ── Users tab ────────────────────────────────────────────────────────────────
function UsersTab() {
function UsersTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const [users, setUsers] = useState<AdminUser[]>([]);
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
@@ -299,6 +308,7 @@ function UsersTab() {
const [selectedPlan, setSelectedPlan] = useState<Record<string, string>>({});
const [assigningRole, setAssigningRole] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<Record<string, string>>({});
const [togglingBan, setTogglingBan] = useState<string | null>(null);
useEffect(() => {
Promise.all([
@@ -325,6 +335,7 @@ function UsersTab() {
});
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
setUsers(r.data.users);
setSelectedPlan((s) => { const n = { ...s }; delete n[userId]; return n; });
} catch {
setActionError('Impossible d\'assigner le plan.');
}
@@ -343,12 +354,28 @@ function UsersTab() {
});
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
setUsers(r.data.users);
setSelectedRole((s) => { const n = { ...s }; delete n[userId]; return n; });
} catch {
setActionError('Impossible d\'assigner le rôle.');
}
setAssigningRole(null);
}
async function toggleBan(u: AdminUser) {
setTogglingBan(u.id);
setActionError(null);
try {
await apiFetch(`/admin/users/${u.id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !u.isActive }),
});
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, isActive: !u.isActive } : x));
} catch {
setActionError('Impossible de modifier le statut.');
}
setTogglingBan(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" />)}
@@ -357,14 +384,29 @@ function UsersTab() {
if (error) return <p className="text-sm text-od-crit">{error}</p>;
// Rôles assignables selon le niveau du current user
const assignableRoles = isSuperAdmin
? ['user', 'moderator', 'admin', 'super_admin']
: ['user', 'moderator'];
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
key={u.id}
className={`flex flex-col gap-2 rounded border bg-od-surface px-4 py-3 ${
u.isActive ? 'border-od-border' : 'border-od-crit/40 opacity-60'
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-od-text">{u.nickname}</p>
<p className="text-sm font-medium text-od-text">
{u.nickname}
{!u.isActive && (
<span className="ml-2 font-mono text-xs text-od-crit">banni</span>
)}
</p>
<p className="font-mono text-xs text-od-muted">{u.email ?? '—'}</p>
</div>
<div className="flex flex-col items-end gap-1">
@@ -382,11 +424,11 @@ function UsersTab() {
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap 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"
className="flex-1 min-w-0 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) => (
@@ -406,9 +448,7 @@ function UsersTab() {
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>
{assignableRoles.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<button
disabled={!selectedRole[u.id] || assigningRole === u.id}
@@ -417,6 +457,17 @@ function UsersTab() {
>
{assigningRole === u.id ? '…' : 'Rôle'}
</button>
<button
disabled={togglingBan === u.id}
onClick={() => toggleBan(u)}
className={`rounded border px-3 py-1 font-mono text-xs transition-colors disabled:opacity-40 ${
u.isActive
? 'border-od-border text-od-muted hover:border-od-crit hover:text-od-crit'
: 'border-od-crit text-od-crit hover:bg-od-crit hover:text-od-bg'
}`}
>
{togglingBan === u.id ? '…' : u.isActive ? 'Bannir' : 'Débannir'}
</button>
</div>
</div>
))}
@@ -559,3 +610,51 @@ function PlansTab() {
</div>
);
}
// ── System tab (super_admin only) ─────────────────────────────────────────────
function SystemTab() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
apiFetch<{ success: boolean; data: Stats }>('/admin/stats')
.then((r) => setStats(r.data))
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
return (
<div className="flex flex-col gap-6">
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">Métriques plateforme</p>
{loading && (
<div className="grid gap-4 sm:grid-cols-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-20 rounded border border-od-border animate-pulse" />
))}
</div>
)}
{error && <p className="text-sm text-od-crit">Impossible de charger les stats.</p>}
{stats && (
<div className="grid gap-4 sm:grid-cols-3">
<StatCard label="Utilisateurs" value={stats.totalUsers} />
<StatCard label="Vidéos publiées" value={stats.totalVideos} />
<StatCard label="Abonnements actifs" value={stats.activeSubscriptions} />
</div>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="flex flex-col gap-1 rounded border border-od-border bg-od-surface p-4">
<p className="font-mono text-xs text-od-muted uppercase tracking-widest">{label}</p>
<p className="text-2xl font-semibold text-od-accent">{value}</p>
</div>
);
}