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
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 24s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user