feat: secure admin panel with Supabase auth + course management CRUD

- Replace ADMIN_SECRET query param with proper Supabase auth + is_admin flag
- Add admin layout with auth check (redirects non-admin to /)
- Add AdminShell component with sidebar navigation (Dashboard, Candidatures, Cours)
- Add admin dashboard with stats (candidatures, users, modules)
- Add admin candidatures page with filters and approve/reject
- Add admin course management page (create, edit, delete, publish/unpublish)
- Add API routes: GET/POST /api/admin/modules, GET/PUT/DELETE /api/admin/modules/[id]
- Add verifyAdmin() helper for API route protection
- Update database types with is_admin on profiles

https://claude.ai/code/session_01H2aRGDaKgarPvhay2HxN6Y
This commit is contained in:
Claude
2026-02-10 13:25:58 +00:00
parent c4934f5669
commit 1d0bd349fd
12 changed files with 1425 additions and 359 deletions

View File

@@ -1,358 +1,143 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { createAdminClient } from "@/lib/supabase/server";
import Link from "next/link";
interface Candidature {
id: string;
email: string;
firstname: string;
phone: string;
persona: string;
age: number;
experience: string;
time_daily: string;
availability: string;
start_date: string;
motivation: string;
monthly_goal: string;
biggest_fear: string;
tiktok_username: string | null;
status: "pending" | "approved" | "rejected";
created_at: string;
}
export const runtime = "nodejs";
export default function AdminPage() {
const [secret, setSecret] = useState("");
const [authenticated, setAuthenticated] = useState(false);
const [candidatures, setCandidatures] = useState<Candidature[]>([]);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [checkoutUrls, setCheckoutUrls] = useState<Record<string, string>>({});
const [expandedId, setExpandedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
export default async function AdminDashboard() {
const supabase = createAdminClient();
const fetchCandidatures = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/candidatures?secret=${encodeURIComponent(secret)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setCandidatures(data.candidatures);
setAuthenticated(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur de chargement");
} finally {
setLoading(false);
}
}, [secret]);
// Récupérer les stats en parallèle
const [candidaturesRes, modulesRes, profilesRes] = await Promise.all([
supabase.from("candidatures").select("*"),
supabase.from("modules").select("*"),
supabase.from("profiles").select("*"),
]);
useEffect(() => {
// Vérifier si le secret est dans l'URL
const params = new URLSearchParams(window.location.search);
const urlSecret = params.get("secret");
if (urlSecret) {
setSecret(urlSecret);
}
}, []);
const candidatures = (candidaturesRes.data || []) as { id: string; status: string; created_at: string }[];
const modules = (modulesRes.data || []) as { id: string; is_published: boolean }[];
const profiles = (profilesRes.data || []) as { id: string; subscription_status: string; created_at: string }[];
useEffect(() => {
if (secret && !authenticated) {
fetchCandidatures();
}
}, [secret, authenticated, fetchCandidatures]);
const pendingCount = candidatures.filter((c) => c.status === "pending").length;
const approvedCount = candidatures.filter((c) => c.status === "approved").length;
const publishedModules = modules.filter((m) => m.is_published).length;
const activeUsers = profiles.filter((p) => p.subscription_status === "active").length;
const handleApprove = async (id: string) => {
setActionLoading(id);
try {
const res = await fetch(`/api/admin/candidatures/${id}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
if (data.checkoutUrl) {
setCheckoutUrls((prev) => ({ ...prev, [id]: data.checkoutUrl }));
}
// Rafraîchir la liste
await fetchCandidatures();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur");
} finally {
setActionLoading(null);
}
};
const handleReject = async (id: string) => {
if (!confirm("Rejeter cette candidature ?")) return;
setActionLoading(id);
try {
const res = await fetch(`/api/admin/candidatures/${id}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
await fetchCandidatures();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur");
} finally {
setActionLoading(null);
}
};
const statusColors: Record<string, string> = {
pending: "bg-warning/10 text-warning",
approved: "bg-success/10 text-success",
rejected: "bg-error/10 text-error",
};
const statusLabels: Record<string, string> = {
pending: "En attente",
approved: "Approuvée",
rejected: "Rejetée",
};
// Login form
if (!authenticated) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="bg-dark-light border border-dark-border rounded-[20px] p-8 w-full max-w-sm">
<h1 className="text-xl font-bold text-white mb-6 text-center">Admin HookLab</h1>
<form
onSubmit={(e) => {
e.preventDefault();
fetchCandidatures();
}}
>
<input
type="password"
placeholder="Clé secrète admin"
value={secret}
onChange={(e) => setSecret(e.target.value)}
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm mb-4 focus:outline-none focus:border-primary"
/>
{error && (
<p className="text-error text-sm mb-4">{error}</p>
)}
<button
type="submit"
disabled={loading || !secret}
className="w-full py-3 gradient-bg text-white font-semibold rounded-xl disabled:opacity-50 cursor-pointer"
>
{loading ? "Connexion..." : "Accéder"}
</button>
</form>
</div>
</main>
);
}
const pending = candidatures.filter((c) => c.status === "pending");
const approved = candidatures.filter((c) => c.status === "approved");
const rejected = candidatures.filter((c) => c.status === "rejected");
// Candidatures récentes (5 dernières)
const recentCandidatures = candidatures
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 5);
return (
<main className="min-h-screen py-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<Link href="/" className="text-white/40 hover:text-white text-sm transition-colors mb-2 inline-block">
&larr; Retour au site
<div className="max-w-6xl">
<div className="mb-10">
<h1 className="text-3xl font-bold text-white mb-2">Dashboard Admin</h1>
<p className="text-white/60">Vue d&apos;ensemble de HookLab.</p>
</div>
{/* Stats cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<p className="text-white/40 text-sm mb-1">Candidatures en attente</p>
<p className="text-3xl font-bold text-warning">{pendingCount}</p>
<p className="text-white/30 text-xs mt-1">{candidatures.length} au total</p>
</div>
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<p className="text-white/40 text-sm mb-1">Candidatures approuvées</p>
<p className="text-3xl font-bold text-success">{approvedCount}</p>
<p className="text-white/30 text-xs mt-1">
{candidatures.length > 0 ? Math.round((approvedCount / candidatures.length) * 100) : 0}% de conversion
</p>
</div>
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<p className="text-white/40 text-sm mb-1">Utilisateurs actifs</p>
<p className="text-3xl font-bold text-primary">{activeUsers}</p>
<p className="text-white/30 text-xs mt-1">{profiles.length} inscrits</p>
</div>
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<p className="text-white/40 text-sm mb-1">Cours publiés</p>
<p className="text-3xl font-bold text-white">{publishedModules}</p>
<p className="text-white/30 text-xs mt-1">{modules.length} au total</p>
</div>
</div>
{/* Actions rapides */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
{/* Candidatures récentes */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-white">Candidatures récentes</h2>
<Link href="/admin/candidatures" className="text-primary text-sm hover:underline">
Tout voir
</Link>
<h1 className="text-2xl font-bold text-white">Gestion des candidatures</h1>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-2 text-xs">
<span className="px-2 py-1 bg-warning/10 text-warning rounded-lg">{pending.length} en attente</span>
<span className="px-2 py-1 bg-success/10 text-success rounded-lg">{approved.length} approuvées</span>
<span className="px-2 py-1 bg-error/10 text-error rounded-lg">{rejected.length} rejetées</span>
{recentCandidatures.length === 0 ? (
<p className="text-white/30 text-sm">Aucune candidature.</p>
) : (
<div className="space-y-3">
{recentCandidatures.map((c) => (
<div key={c.id} className="flex items-center justify-between">
<span className="text-white/60 text-sm truncate">
{new Date(c.created_at).toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
})}
</span>
<span
className={`px-2 py-0.5 rounded-lg text-xs font-medium ${
c.status === "pending"
? "bg-warning/10 text-warning"
: c.status === "approved"
? "bg-success/10 text-success"
: "bg-error/10 text-error"
}`}
>
{c.status === "pending" ? "En attente" : c.status === "approved" ? "Approuvée" : "Rejetée"}
</span>
</div>
))}
</div>
<button
onClick={fetchCandidatures}
className="px-3 py-1.5 bg-dark-lighter border border-dark-border rounded-lg text-white/60 text-sm hover:text-white transition-colors cursor-pointer"
>
Rafraîchir
</button>
</div>
)}
</div>
{error && (
<div className="mb-6 p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
{/* Liste des candidatures */}
{candidatures.length === 0 ? (
<div className="text-center py-20">
<p className="text-white/40">Aucune candidature pour le moment.</p>
</div>
) : (
{/* Actions rapides */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
<h2 className="text-lg font-bold text-white mb-4">Actions rapides</h2>
<div className="space-y-3">
{candidatures.map((c) => (
<div
key={c.id}
className="bg-dark-light border border-dark-border rounded-2xl overflow-hidden"
>
{/* Header row */}
<div
className="px-6 py-4 flex items-center justify-between cursor-pointer hover:bg-dark-lighter/50 transition-colors"
onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full gradient-bg flex items-center justify-center text-sm font-bold text-white">
{c.firstname.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-white font-medium">{c.firstname}</p>
<p className="text-white/40 text-sm">{c.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-white/30 text-xs">
{new Date(c.created_at).toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
<span className={`px-2.5 py-1 rounded-lg text-xs font-medium ${statusColors[c.status]}`}>
{statusLabels[c.status]}
</span>
<svg
className={`w-5 h-5 text-white/30 transition-transform ${expandedId === c.id ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Expanded details */}
{expandedId === c.id && (
<div className="px-6 pb-5 border-t border-dark-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4">
<div>
<p className="text-white/40 text-xs mb-1">Téléphone</p>
<p className="text-white text-sm">{c.phone}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Âge</p>
<p className="text-white text-sm">{c.age} ans</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Profil</p>
<p className="text-white text-sm capitalize">{c.persona}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Expérience</p>
<p className="text-white text-sm">{c.experience}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Temps disponible</p>
<p className="text-white text-sm">{c.time_daily}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Disponibilité</p>
<p className="text-white text-sm">{c.availability}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Début souhaité</p>
<p className="text-white text-sm">{c.start_date}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Objectif mensuel</p>
<p className="text-white text-sm">{c.monthly_goal}</p>
</div>
{c.tiktok_username && (
<div>
<p className="text-white/40 text-xs mb-1">TikTok</p>
<p className="text-white text-sm">{c.tiktok_username}</p>
</div>
)}
</div>
<div className="space-y-3 py-3">
<div>
<p className="text-white/40 text-xs mb-1">Motivation</p>
<p className="text-white/80 text-sm bg-dark-lighter rounded-xl p-3">{c.motivation}</p>
</div>
<div>
<p className="text-white/40 text-xs mb-1">Plus grande peur</p>
<p className="text-white/80 text-sm bg-dark-lighter rounded-xl p-3">{c.biggest_fear}</p>
</div>
</div>
{/* Checkout URL si disponible */}
{checkoutUrls[c.id] && (
<div className="mt-3 p-3 bg-success/10 border border-success/20 rounded-xl">
<p className="text-success text-xs font-medium mb-1">Lien de paiement généré :</p>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={checkoutUrls[c.id]}
className="flex-1 bg-dark-lighter border border-dark-border rounded-lg px-3 py-2 text-white text-xs"
/>
<button
onClick={() => navigator.clipboard.writeText(checkoutUrls[c.id])}
className="px-3 py-2 bg-success/20 text-success rounded-lg text-xs font-medium hover:bg-success/30 transition-colors cursor-pointer"
>
Copier
</button>
</div>
</div>
)}
{/* Actions */}
{c.status === "pending" && (
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-dark-border">
<button
onClick={() => handleApprove(c.id)}
disabled={actionLoading === c.id}
className="px-4 py-2 bg-success/10 text-success border border-success/20 rounded-xl text-sm font-medium hover:bg-success/20 transition-colors disabled:opacity-50 cursor-pointer"
>
{actionLoading === c.id ? "Approbation..." : "Approuver + Envoyer lien paiement"}
</button>
<button
onClick={() => handleReject(c.id)}
disabled={actionLoading === c.id}
className="px-4 py-2 bg-error/10 text-error border border-error/20 rounded-xl text-sm font-medium hover:bg-error/20 transition-colors disabled:opacity-50 cursor-pointer"
>
Rejeter
</button>
</div>
)}
{c.status === "approved" && !checkoutUrls[c.id] && (
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-dark-border">
<button
onClick={() => handleApprove(c.id)}
disabled={actionLoading === c.id}
className="px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-xl text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50 cursor-pointer"
>
{actionLoading === c.id ? "Génération..." : "Regénérer le lien de paiement"}
</button>
</div>
)}
</div>
)}
<Link
href="/admin/candidatures"
className="flex items-center gap-3 p-3 rounded-xl bg-dark-lighter hover:bg-dark-lighter/80 transition-colors group"
>
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center">
<svg className="w-5 h-5 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
))}
<div>
<p className="text-white text-sm font-medium group-hover:text-primary transition-colors">
Gérer les candidatures
</p>
<p className="text-white/30 text-xs">{pendingCount} en attente de traitement</p>
</div>
</Link>
<Link
href="/admin/cours"
className="flex items-center gap-3 p-3 rounded-xl bg-dark-lighter hover:bg-dark-lighter/80 transition-colors group"
>
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<div>
<p className="text-white text-sm font-medium group-hover:text-primary transition-colors">
Ajouter un cours
</p>
<p className="text-white/30 text-xs">{publishedModules} cours publiés</p>
</div>
</Link>
</div>
)}
</div>
</div>
</main>
</div>
);
}