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

@@ -99,7 +99,7 @@ router.get("/videos", async (req: Request, res: Response): Promise<void> => {
skip: (rawPage - 1) * rawLimit, skip: (rawPage - 1) * rawLimit,
take: rawLimit, take: rawLimit,
}); });
res.json({ success: true, data: videos, total, page: rawPage, limit: rawLimit }); res.json({ success: true, data: { videos }, total, page: rawPage, limit: rawLimit });
} catch (err) { } catch (err) {
logger.error("GET /admin/videos — failed to list videos", { err }); logger.error("GET /admin/videos — failed to list videos", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
@@ -262,13 +262,43 @@ router.get("/users", async (req: Request, res: Response): Promise<void> => {
})(), })(),
})); }));
res.json({ success: true, data, total, page: rawPage, limit: rawLimit }); res.json({ success: true, data: { users: data }, total, page: rawPage, limit: rawLimit });
} catch (err) { } catch (err) {
logger.error("GET /admin/users — failed to list users", { err }); logger.error("GET /admin/users — failed to list users", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" }); res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
} }
}); });
/**
* PATCH /api/admin/users/:id
* Met à jour isActive (ban / unban) d'un utilisateur.
*/
router.patch("/users/:id", async (req: Request, res: Response): Promise<void> => {
const { isActive } = req.body as { isActive?: boolean };
if (typeof isActive !== "boolean") {
res.status(400).json({ success: false, error: "INVALID_BODY" });
return;
}
try {
const repo = AppDataSource.getRepository(User);
const user = await repo.findOne({ where: { id: req.params.id } });
if (!user) {
res.status(404).json({ success: false, error: "NOT_FOUND" });
return;
}
user.isActive = isActive;
await repo.save(user);
res.json({ success: true, data: { userId: user.id, isActive: user.isActive } });
} catch (err) {
logger.error("PATCH /admin/users/:id — failed to update user", { err, id: req.params.id });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
/** /**
* PATCH /api/admin/users/:id/roles * PATCH /api/admin/users/:id/roles
* Assigne des rôles à un utilisateur (remplace les rôles existants). * Assigne des rôles à un utilisateur (remplace les rôles existants).
@@ -332,6 +362,28 @@ router.patch("/users/:id/roles", async (req: Request, res: Response): Promise<vo
} }
}); });
// ---------------------------------------------------------------------------
// STATS (super_admin)
// ---------------------------------------------------------------------------
/**
* GET /api/admin/stats
* Métriques globales de la plateforme.
*/
router.get("/stats", async (_req: Request, res: Response): Promise<void> => {
try {
const [totalUsers, totalVideos, activeSubscriptions] = await Promise.all([
AppDataSource.getRepository(User).count(),
AppDataSource.getRepository(Video).count({ where: { isPublished: true } }),
AppDataSource.getRepository(UserSubscription).count({ where: { status: "active" } }),
]);
res.json({ success: true, data: { totalUsers, totalVideos, activeSubscriptions } });
} catch (err) {
logger.error("GET /admin/stats — failed", { err });
res.status(500).json({ success: false, error: "INTERNAL_ERROR" });
}
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SUBSCRIPTION PLANS // SUBSCRIPTION PLANS
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -41,21 +41,32 @@ interface AdminUser {
} | null; } | null;
} }
interface Stats {
totalUsers: number;
totalVideos: number;
activeSubscriptions: number;
}
// ── Tabs ───────────────────────────────────────────────────────────────────── // ── Tabs ─────────────────────────────────────────────────────────────────────
type Tab = 'videos' | 'users' | 'plans'; type Tab = 'videos' | 'users' | 'plans' | 'system';
export default function AdminPage() { export default function AdminPage() {
const { user, loading: authLoading } = useAuthContext(); const { user, loading: authLoading } = useAuthContext();
const isSuperAdmin = user?.roles?.includes('super_admin') ?? false;
const [tab, setTab] = useState<Tab>('videos'); const [tab, setTab] = useState<Tab>('videos');
if (authLoading) return null; if (authLoading) return null;
if (!user?.roles?.some((r) => r === 'admin' || r === 'super_admin')) return <Navigate to="/" replace />; 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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-1 border-b border-od-border pb-4"> <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 <button
key={t} key={t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
@@ -71,8 +82,9 @@ export default function AdminPage() {
</div> </div>
{tab === 'videos' && <VideosTab />} {tab === 'videos' && <VideosTab />}
{tab === 'users' && <UsersTab />} {tab === 'users' && <UsersTab isSuperAdmin={isSuperAdmin} />}
{tab === 'plans' && <PlansTab />} {tab === 'plans' && <PlansTab />}
{tab === 'system' && isSuperAdmin && <SystemTab />}
</div> </div>
); );
} }
@@ -166,7 +178,6 @@ function VideosTab() {
return ( return (
<div className="flex flex-col gap-6"> <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"> <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> <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"> <label className="flex items-center gap-2 text-sm text-od-muted">
Niveau requis Niveau requis
<input <input
type="number" type="number" min={0}
min={0}
value={form.requiredLevel} value={form.requiredLevel}
onChange={(e) => setForm((f) => ({ ...f, requiredLevel: parseInt(e.target.value) || 0 }))} 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" 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> </button>
</form> </form>
{/* Liste */}
{loading ? ( {loading ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{[...Array(3)].map((_, i) => <div key={i} className="h-12 rounded border border-od-border animate-pulse" />)} {[...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 ──────────────────────────────────────────────────────────────── // ── Users tab ────────────────────────────────────────────────────────────────
function UsersTab() { function UsersTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [plans, setPlans] = useState<Plan[]>([]); const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -299,6 +308,7 @@ function UsersTab() {
const [selectedPlan, setSelectedPlan] = useState<Record<string, string>>({}); const [selectedPlan, setSelectedPlan] = useState<Record<string, string>>({});
const [assigningRole, setAssigningRole] = useState<string | null>(null); const [assigningRole, setAssigningRole] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<Record<string, string>>({}); const [selectedRole, setSelectedRole] = useState<Record<string, string>>({});
const [togglingBan, setTogglingBan] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -325,6 +335,7 @@ function UsersTab() {
}); });
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users'); const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
setUsers(r.data.users); setUsers(r.data.users);
setSelectedPlan((s) => { const n = { ...s }; delete n[userId]; return n; });
} catch { } catch {
setActionError('Impossible d\'assigner le plan.'); setActionError('Impossible d\'assigner le plan.');
} }
@@ -343,12 +354,28 @@ function UsersTab() {
}); });
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users'); const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
setUsers(r.data.users); setUsers(r.data.users);
setSelectedRole((s) => { const n = { ...s }; delete n[userId]; return n; });
} catch { } catch {
setActionError('Impossible d\'assigner le rôle.'); setActionError('Impossible d\'assigner le rôle.');
} }
setAssigningRole(null); 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 ( if (loading) return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{[...Array(3)].map((_, i) => <div key={i} className="h-20 rounded border border-od-border animate-pulse" />)} {[...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>; 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 ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{actionError && <p className="text-xs text-od-crit">{actionError}</p>} {actionError && <p className="text-xs text-od-crit">{actionError}</p>}
{users.map((u) => ( {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 className="flex items-start justify-between gap-2">
<div> <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> <p className="font-mono text-xs text-od-muted">{u.email ?? '—'}</p>
</div> </div>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
@@ -382,11 +424,11 @@ function UsersTab() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <select
value={selectedPlan[u.id] ?? ''} value={selectedPlan[u.id] ?? ''}
onChange={(e) => setSelectedPlan((s) => ({ ...s, [u.id]: e.target.value }))} 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> <option value=""> Plan </option>
{plans.filter((p) => p.isActive).map((p) => ( {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" 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=""> Rôle </option>
<option value="user">user</option> {assignableRoles.map((r) => <option key={r} value={r}>{r}</option>)}
<option value="admin">admin</option>
<option value="super_admin">super_admin</option>
</select> </select>
<button <button
disabled={!selectedRole[u.id] || assigningRole === u.id} disabled={!selectedRole[u.id] || assigningRole === u.id}
@@ -417,6 +457,17 @@ function UsersTab() {
> >
{assigningRole === u.id ? '…' : 'Rôle'} {assigningRole === u.id ? '…' : 'Rôle'}
</button> </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>
</div> </div>
))} ))}
@@ -559,3 +610,51 @@ function PlansTab() {
</div> </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>
);
}