From 1d0bd349fd45d5408bc119e316a5384a660c9605 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 13:25:58 +0000 Subject: [PATCH] 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 --- app/admin/candidatures/page.tsx | 322 ++++++++++ app/admin/cours/page.tsx | 563 ++++++++++++++++++ app/admin/layout.tsx | 46 ++ app/admin/page.tsx | 461 ++++---------- .../admin/candidatures/[id]/approve/route.ts | 13 +- .../admin/candidatures/[id]/reject/route.ts | 13 +- app/api/admin/candidatures/route.ts | 13 +- app/api/admin/modules/[id]/route.ts | 104 ++++ app/api/admin/modules/route.ts | 65 ++ components/admin/AdminShell.tsx | 139 +++++ lib/admin.ts | 40 ++ types/database.types.ts | 5 + 12 files changed, 1425 insertions(+), 359 deletions(-) create mode 100644 app/admin/candidatures/page.tsx create mode 100644 app/admin/cours/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/api/admin/modules/[id]/route.ts create mode 100644 app/api/admin/modules/route.ts create mode 100644 components/admin/AdminShell.tsx create mode 100644 lib/admin.ts diff --git a/app/admin/candidatures/page.tsx b/app/admin/candidatures/page.tsx new file mode 100644 index 0000000..08ec1d0 --- /dev/null +++ b/app/admin/candidatures/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +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 default function AdminCandidaturesPage() { + const [candidatures, setCandidatures] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [checkoutUrls, setCheckoutUrls] = useState>({}); + const [expandedId, setExpandedId] = useState(null); + const [error, setError] = useState(null); + const [filter, setFilter] = useState<"all" | "pending" | "approved" | "rejected">("all"); + + const fetchCandidatures = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/admin/candidatures"); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setCandidatures(data.candidatures); + } catch (err) { + setError(err instanceof Error ? err.message : "Erreur de chargement"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchCandidatures(); + }, [fetchCandidatures]); + + const handleApprove = async (id: string) => { + setActionLoading(id); + try { + const res = await fetch(`/api/admin/candidatures/${id}/approve`, { + method: "POST", + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + + if (data.checkoutUrl) { + setCheckoutUrls((prev) => ({ ...prev, [id]: data.checkoutUrl })); + } + + 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", + }); + 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 = { + pending: "bg-warning/10 text-warning", + approved: "bg-success/10 text-success", + rejected: "bg-error/10 text-error", + }; + + const statusLabels: Record = { + pending: "En attente", + approved: "Approuvée", + rejected: "Rejetée", + }; + + const filtered = filter === "all" ? candidatures : candidatures.filter((c) => c.status === filter); + const pending = candidatures.filter((c) => c.status === "pending"); + const approved = candidatures.filter((c) => c.status === "approved"); + const rejected = candidatures.filter((c) => c.status === "rejected"); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Candidatures

+

{candidatures.length} candidature(s) au total

+
+ +
+ + {/* Filtres */} +
+ {[ + { key: "all" as const, label: "Toutes", count: candidatures.length }, + { key: "pending" as const, label: "En attente", count: pending.length }, + { key: "approved" as const, label: "Approuvées", count: approved.length }, + { key: "rejected" as const, label: "Rejetées", count: rejected.length }, + ].map((f) => ( + + ))} +
+ + {error && ( +
+

{error}

+
+ )} + + {/* Liste */} + {filtered.length === 0 ? ( +
+

Aucune candidature dans cette catégorie.

+
+ ) : ( +
+ {filtered.map((c) => ( +
+ {/* Header row */} +
setExpandedId(expandedId === c.id ? null : c.id)} + > +
+
+ {c.firstname.charAt(0).toUpperCase()} +
+
+

{c.firstname}

+

{c.email}

+
+
+
+ + {new Date(c.created_at).toLocaleDateString("fr-FR", { + day: "numeric", + month: "short", + year: "numeric", + })} + + + {statusLabels[c.status]} + + + + +
+
+ + {/* Expanded details */} + {expandedId === c.id && ( +
+
+
+

Téléphone

+

{c.phone}

+
+
+

Âge

+

{c.age} ans

+
+
+

Profil

+

{c.persona}

+
+
+

Expérience

+

{c.experience}

+
+
+

Temps disponible

+

{c.time_daily}

+
+
+

Disponibilité

+

{c.availability}

+
+
+

Début souhaité

+

{c.start_date}

+
+
+

Objectif mensuel

+

{c.monthly_goal}

+
+ {c.tiktok_username && ( +
+

TikTok

+

{c.tiktok_username}

+
+ )} +
+ +
+
+

Motivation

+

{c.motivation}

+
+
+

Plus grande peur

+

{c.biggest_fear}

+
+
+ + {/* Checkout URL */} + {checkoutUrls[c.id] && ( +
+

Lien de paiement généré :

+
+ + +
+
+ )} + + {/* Actions */} + {c.status === "pending" && ( +
+ + +
+ )} + + {c.status === "approved" && !checkoutUrls[c.id] && ( +
+ +
+ )} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/app/admin/cours/page.tsx b/app/admin/cours/page.tsx new file mode 100644 index 0000000..bb567af --- /dev/null +++ b/app/admin/cours/page.tsx @@ -0,0 +1,563 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +interface Module { + id: string; + title: string; + description: string | null; + week_number: number; + order_index: number; + content_type: "video" | "pdf" | "text" | "quiz" | null; + content_url: string | null; + duration_minutes: number | null; + is_published: boolean; + created_at: string; +} + +type FormData = { + title: string; + description: string; + week_number: number; + order_index: number; + content_type: "video" | "pdf" | "text" | "quiz" | ""; + content_url: string; + duration_minutes: number | ""; + is_published: boolean; +}; + +const emptyForm: FormData = { + title: "", + description: "", + week_number: 1, + order_index: 0, + content_type: "video", + content_url: "", + duration_minutes: "", + is_published: false, +}; + +export default function AdminCoursPage() { + const [modules, setModules] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Vue : "list" ou "form" + const [view, setView] = useState<"list" | "form">("list"); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + const fetchModules = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/admin/modules"); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setModules(data.modules); + } catch (err) { + setError(err instanceof Error ? err.message : "Erreur de chargement"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchModules(); + }, [fetchModules]); + + // Auto-clear success message + useEffect(() => { + if (success) { + const t = setTimeout(() => setSuccess(null), 3000); + return () => clearTimeout(t); + } + }, [success]); + + const openNew = () => { + // Calculer automatiquement le prochain order_index + const maxOrder = modules.reduce((max, m) => Math.max(max, m.order_index), -1); + setForm({ ...emptyForm, order_index: maxOrder + 1 }); + setEditingId(null); + setView("form"); + setError(null); + }; + + const openEdit = (mod: Module) => { + setForm({ + title: mod.title, + description: mod.description || "", + week_number: mod.week_number, + order_index: mod.order_index, + content_type: mod.content_type || "", + content_url: mod.content_url || "", + duration_minutes: mod.duration_minutes ?? "", + is_published: mod.is_published, + }); + setEditingId(mod.id); + setView("form"); + setError(null); + }; + + const handleSave = async () => { + if (!form.title.trim()) { + setError("Le titre est obligatoire."); + return; + } + + setSaving(true); + setError(null); + + try { + const payload = { + title: form.title.trim(), + description: form.description.trim() || null, + week_number: form.week_number, + order_index: form.order_index, + content_type: form.content_type || null, + content_url: form.content_url.trim() || null, + duration_minutes: form.duration_minutes === "" ? null : Number(form.duration_minutes), + is_published: form.is_published, + }; + + let res: Response; + if (editingId) { + res = await fetch(`/api/admin/modules/${editingId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } else { + res = await fetch("/api/admin/modules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } + + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + + setSuccess(editingId ? "Cours mis à jour !" : "Cours créé !"); + setView("list"); + await fetchModules(); + } catch (err) { + setError(err instanceof Error ? err.message : "Erreur"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + try { + const res = await fetch(`/api/admin/modules/${id}`, { method: "DELETE" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setSuccess("Cours supprimé !"); + setDeleteConfirm(null); + await fetchModules(); + } catch (err) { + setError(err instanceof Error ? err.message : "Erreur"); + } + }; + + const handleTogglePublish = async (mod: Module) => { + try { + const res = await fetch(`/api/admin/modules/${mod.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ is_published: !mod.is_published }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + await fetchModules(); + } catch (err) { + setError(err instanceof Error ? err.message : "Erreur"); + } + }; + + const contentTypeLabels: Record = { + video: "Vidéo", + pdf: "PDF", + text: "Texte", + quiz: "Quiz", + }; + + // Grouper par semaine pour l'affichage liste + const modulesByWeek = modules.reduce( + (acc, mod) => { + const w = mod.week_number; + if (!acc[w]) acc[w] = []; + acc[w].push(mod); + return acc; + }, + {} as Record + ); + + if (loading) { + return ( +
+
+
+ ); + } + + // ========== FORMULAIRE ========== + if (view === "form") { + return ( +
+ + +

+ {editingId ? "Modifier le cours" : "Nouveau cours"} +

+ + {error && ( +
+

{error}

+
+ )} + +
+ {/* Titre */} +
+ + setForm({ ...form, title: e.target.value })} + placeholder="Ex : Introduction au TikTok Shop" + className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary" + /> +
+ + {/* Description */} +
+ +