diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..7e91709 --- /dev/null +++ b/app/api/admin/users/[id]/route.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/server' + +async function assertAdmin() { + const supabase = await createClient() + const db = supabase as any + const { data: { user }, error } = await supabase.auth.getUser() + if (error || !user) return null + const { data: profile } = await db.from('profiles').select('role').eq('id', user.id).single() + if (profile?.role !== 'admin') return null + return user +} + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const caller = await assertAdmin() + if (!caller) return NextResponse.json({ error: 'Accès refusé' }, { status: 403 }) + + const { id } = await params + const { role } = await request.json() + + if (!['admin', 'formateur'].includes(role)) { + return NextResponse.json({ error: 'Rôle invalide' }, { status: 400 }) + } + + // Ne pas modifier son propre rôle + if (id === caller.id) { + return NextResponse.json({ error: 'Vous ne pouvez pas modifier votre propre rôle' }, { status: 400 }) + } + + const admin = createAdminClient() + const { error: updateError } = await (admin as any) + .from('profiles') + .update({ role }) + .eq('id', id) + + if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const caller = await assertAdmin() + if (!caller) return NextResponse.json({ error: 'Accès refusé' }, { status: 403 }) + + const { id } = await params + + if (id === caller.id) { + return NextResponse.json({ error: 'Vous ne pouvez pas supprimer votre propre compte' }, { status: 400 }) + } + + // auth.admin.deleteUser est défaillant sur ce projet — on passe par une fonction SQL SECURITY DEFINER + const admin = createAdminClient() as any + const { error: deleteError } = await admin.rpc('admin_delete_user', { user_id: id }) + if (deleteError) return NextResponse.json({ error: deleteError.message }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch (err) { + console.error('[admin/users/delete]', err) + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} diff --git a/app/api/admin/users/create/route.ts b/app/api/admin/users/create/route.ts new file mode 100644 index 0000000..96dd84a --- /dev/null +++ b/app/api/admin/users/create/route.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/server' + +async function assertAdmin() { + const supabase = await createClient() + const db = supabase as any + const { data: { user }, error } = await supabase.auth.getUser() + if (error || !user) return null + const { data: profile } = await db.from('profiles').select('role').eq('id', user.id).single() + if (profile?.role !== 'admin') return null + return user +} + +export async function POST(request: NextRequest) { + try { + const caller = await assertAdmin() + if (!caller) return NextResponse.json({ error: 'Accès refusé' }, { status: 403 }) + + const { email, password, username, role } = await request.json() + + if (!email?.trim() || !password?.trim() || !username?.trim()) { + return NextResponse.json({ error: 'Email, mot de passe et nom d\'utilisateur sont requis' }, { status: 400 }) + } + if (!['admin', 'formateur'].includes(role)) { + return NextResponse.json({ error: 'Rôle invalide' }, { status: 400 }) + } + if (password.length < 8) { + return NextResponse.json({ error: 'Le mot de passe doit contenir au moins 8 caractères' }, { status: 400 }) + } + + const admin = createAdminClient() + + // Créer l'utilisateur Supabase Auth (le trigger handle_new_user créera le profil) + const { data: newUser, error: createError } = await admin.auth.admin.createUser({ + email: email.trim(), + password, + email_confirm: true, + user_metadata: { + username: username.trim(), + role, + }, + }) + + if (createError) { + const msg = createError.message.includes('already') + ? 'Un compte avec cet email existe déjà' + : createError.message + return NextResponse.json({ error: msg }, { status: 400 }) + } + + return NextResponse.json({ + success: true, + user: { + id: newUser.user.id, + email: newUser.user.email, + username: username.trim(), + role, + }, + }) + } catch { + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000..ed3c3eb --- /dev/null +++ b/app/api/admin/users/route.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/server' + +async function assertAdmin() { + const supabase = await createClient() + const db = supabase as any + const { data: { user }, error } = await supabase.auth.getUser() + if (error || !user) return null + const { data: profile } = await db.from('profiles').select('role').eq('id', user.id).single() + if (profile?.role !== 'admin') return null + return user +} + +export async function GET() { + try { + const caller = await assertAdmin() + if (!caller) return NextResponse.json({ error: 'Accès refusé' }, { status: 403 }) + + const admin = createAdminClient() + const adminDb = admin as any + + // Récupérer tous les utilisateurs via la fonction SECURITY DEFINER + const { data: authUsers, error: authError } = await adminDb.rpc('get_all_users_admin') + if (authError) return NextResponse.json({ error: authError.message }, { status: 500 }) + + // Récupérer tous les profils + const { data: profiles, error: profilesError } = await adminDb + .from('profiles') + .select('id, username, role, created_at') + .order('created_at', { ascending: false }) + + if (profilesError) return NextResponse.json({ error: profilesError.message }, { status: 500 }) + + // Merger par user ID + const profileMap = new Map((profiles ?? []).map((p: any) => [p.id, p])) + const users = (authUsers ?? []).map((authUser: any) => { + const profile: any = profileMap.get(authUser.id) ?? {} + return { + id: authUser.id, + email: authUser.email, + username: profile.username ?? authUser.email?.split('@')[0] ?? '—', + role: profile.role ?? 'formateur', + created_at: profile.created_at ?? authUser.created_at, + last_sign_in: authUser.last_sign_in_at ?? null, + } + }) + + return NextResponse.json({ users }) + } catch { + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} diff --git a/app/api/answers/[id]/route.ts b/app/api/answers/[id]/route.ts new file mode 100644 index 0000000..1ee8260 --- /dev/null +++ b/app/api/answers/[id]/route.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const supabase = await createClient() + const db = supabase as any + + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + } + + const { id } = await params + const { answer_text, is_correct } = await request.json() + + // Récupérer la réponse avec sa question et le quiz pour vérifier l'ownership + const { data: answer, error: fetchError } = await db + .from('answers') + .select('id, question_id, question:questions!inner(id, quiz:quizzes!inner(author_id))') + .eq('id', id) + .single() + + if (fetchError || !answer || answer.question?.quiz?.author_id !== user.id) { + return NextResponse.json({ error: 'Réponse introuvable ou accès refusé' }, { status: 404 }) + } + + const questionId = answer.question_id + const updates: Record = {} + + if (answer_text !== undefined) { + if (!answer_text.trim()) { + return NextResponse.json({ error: 'Texte de la réponse requis' }, { status: 400 }) + } + updates.answer_text = answer_text.trim() + } + + if (is_correct === true) { + // Remettre toutes les autres réponses de cette question à false + await db + .from('answers') + .update({ is_correct: false }) + .eq('question_id', questionId) + .neq('id', id) + + updates.is_correct = true + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: 'Aucune modification fournie' }, { status: 400 }) + } + + const { data, error } = await db + .from('answers') + .update(updates) + .eq('id', id) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ success: true, answer: data }) + } catch (error) { + console.error('[answers/patch]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/api/questions/[id]/route.ts b/app/api/questions/[id]/route.ts new file mode 100644 index 0000000..38c4cc6 --- /dev/null +++ b/app/api/questions/[id]/route.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const supabase = await createClient() + const db = supabase as any + + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + } + + const { id } = await params + const { question_text, explanation } = await request.json() + + if (!question_text?.trim()) { + return NextResponse.json({ error: 'Texte de la question requis' }, { status: 400 }) + } + + // Vérifier que la question appartient à un quiz de l'utilisateur + const { data: question, error: fetchError } = await db + .from('questions') + .select('id, quiz:quizzes!inner(author_id)') + .eq('id', id) + .single() + + if (fetchError || !question || question.quiz?.author_id !== user.id) { + return NextResponse.json({ error: 'Question introuvable ou accès refusé' }, { status: 404 }) + } + + const { data, error } = await db + .from('questions') + .update({ + question_text: question_text.trim(), + explanation: explanation?.trim() || null, + }) + .eq('id', id) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ success: true, question: data }) + } catch (error) { + console.error('[questions/patch]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/api/quizzes/[id]/route.ts b/app/api/quizzes/[id]/route.ts new file mode 100644 index 0000000..58eaf2e --- /dev/null +++ b/app/api/quizzes/[id]/route.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const supabase = await createClient() + const db = supabase as any + + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + } + + const { id } = await params + + const { data: quiz, error } = await db + .from('quizzes') + .select(` + id, title, updated_at, + subchapter:subchapters(id, name, category:categories(id, name)), + questions( + id, question_text, explanation, order, + answers(id, answer_text, is_correct) + ) + `) + .eq('id', id) + .eq('author_id', user.id) + .single() + + if (error || !quiz) { + return NextResponse.json({ error: 'Quiz introuvable ou accès refusé' }, { status: 404 }) + } + + // Trier les questions par ordre + quiz.questions?.sort((a: any, b: any) => a.order - b.order) + + return NextResponse.json({ quiz }) + } catch (error) { + console.error('[quizzes/get]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const supabase = await createClient() + const db = supabase as any + + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + } + + const { id } = await params + const { title } = await request.json() + + if (!title?.trim()) { + return NextResponse.json({ error: 'Titre requis' }, { status: 400 }) + } + + const { data, error } = await db + .from('quizzes') + .update({ title: title.trim() }) + .eq('id', id) + .eq('author_id', user.id) + .select() + .single() + + if (error || !data) { + return NextResponse.json({ error: 'Quiz introuvable ou accès refusé' }, { status: 404 }) + } + + return NextResponse.json({ success: true, quiz: data }) + } catch (error) { + console.error('[quizzes/patch]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts new file mode 100644 index 0000000..2a46862 --- /dev/null +++ b/app/api/sessions/[id]/route.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient, createAdminClient } from '@/lib/supabase/server' + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Authentification via le client SSR (cookie) + const supabase = await createClient() + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + } + + const { id } = await params + // Utiliser le client admin pour contourner le RLS (pas de policy DELETE sur sessions) + const db = createAdminClient() as any + + // Vérifier manuellement que la session appartient au formateur connecté + const { data: session, error: fetchError } = await db + .from('sessions') + .select('id, is_active, trainer_id') + .eq('id', id) + .single() + + if (fetchError || !session) { + return NextResponse.json({ error: 'Session introuvable' }, { status: 404 }) + } + + if (session.trainer_id !== user.id) { + return NextResponse.json({ error: 'Accès refusé' }, { status: 403 }) + } + + if (session.is_active) { + return NextResponse.json( + { error: "Impossible de supprimer une session active. Terminez-la d'abord." }, + { status: 400 } + ) + } + + const { error: deleteError } = await db + .from('sessions') + .delete() + .eq('id', id) + + if (deleteError) { + return NextResponse.json( + { error: 'Erreur lors de la suppression', details: deleteError.message }, + { status: 500 } + ) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('[sessions/delete]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/api/sessions/[id]/toggle/route.ts b/app/api/sessions/[id]/toggle/route.ts index 9701c71..070e986 100644 --- a/app/api/sessions/[id]/toggle/route.ts +++ b/app/api/sessions/[id]/toggle/route.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { createClient, createAdminClient } from '@/lib/supabase/server' +import { calculateScore } from '@/lib/utils' export async function PATCH( request: NextRequest, @@ -8,8 +9,6 @@ export async function PATCH( ) { try { const supabase = await createClient() - const db = supabase as any - const { data: { user }, error: authError } = await supabase.auth.getUser() if (authError || !user) { return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) @@ -19,19 +18,64 @@ export async function PATCH( const body = await request.json() const { is_active } = body + const db = supabase as any + const { data: session, error } = await db .from('sessions') .update({ is_active }) .eq('id', id) .eq('trainer_id', user.id) - .select() + .select('id, quiz_id') .single() if (error || !session) { return NextResponse.json({ error: 'Session introuvable ou accès refusé' }, { status: 404 }) } - return NextResponse.json({ success: true, session }) + // Quand on ferme la session, forcer la complétion des étudiants encore en cours + if (!is_active) { + const admin = createAdminClient() as any + + // Récupérer toutes les participations encore en cours + const { data: pending } = await admin + .from('student_participations') + .select('id') + .eq('session_id', id) + .eq('status', 'in_progress') + + if (pending && pending.length > 0) { + // Compter le nombre total de questions du quiz pour calculer le score + const { count: totalQuestions } = await admin + .from('questions') + .select('id', { count: 'exact', head: true }) + .eq('quiz_id', session.quiz_id) + + const total = totalQuestions ?? 0 + + for (const participation of pending) { + // Récupérer les réponses données et vérifier lesquelles sont correctes + const { data: correctAnswers } = await admin + .from('student_answers') + .select('answer_id, answers!inner(is_correct)') + .eq('participation_id', participation.id) + .eq('answers.is_correct', true) + + const correct = correctAnswers?.length ?? 0 + const score = calculateScore(correct, total) + + await admin + .from('student_participations') + .update({ + status: 'completed', + score, + completed_at: new Date().toISOString(), + }) + .eq('id', participation.id) + } + } + } + + return NextResponse.json({ success: true }) } catch (error) { console.error('[sessions/toggle]', error) return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) diff --git a/app/api/student/finish/route.ts b/app/api/student/finish/route.ts index 3ccccad..dd83619 100644 --- a/app/api/student/finish/route.ts +++ b/app/api/student/finish/route.ts @@ -1,12 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/server' import { calculateScore } from '@/lib/utils' export async function POST(request: NextRequest) { try { - const supabase = await createClient() - const db = supabase as any + const db = createAdminClient() as any const body = await request.json() const { participation_id } = body diff --git a/app/api/student/join/route.ts b/app/api/student/join/route.ts index 14e5830..3d12fd3 100644 --- a/app/api/student/join/route.ts +++ b/app/api/student/join/route.ts @@ -1,11 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/server' export async function POST(request: NextRequest) { try { - const supabase = await createClient() - const db = supabase as any + const db = createAdminClient() as any const body = await request.json() const { short_code, first_name, last_name } = body @@ -19,7 +18,7 @@ export async function POST(request: NextRequest) { const { data: session, error: sessionError } = await db .from('sessions') - .select('id, is_active, quiz_id') + .select('id, is_active, quiz_id, total_participants') .eq('short_code', short_code.toUpperCase()) .single() @@ -34,6 +33,20 @@ export async function POST(request: NextRequest) { ) } + if (session.total_participants > 0) { + const { count } = await db + .from('student_participations') + .select('id', { count: 'exact', head: true }) + .eq('session_id', session.id) + + if (count !== null && count >= session.total_participants) { + return NextResponse.json( + { error: `Cette session est complète (${session.total_participants} participant${session.total_participants > 1 ? 's' : ''} maximum).`, code: 'SESSION_FULL' }, + { status: 403 } + ) + } + } + const { data: participation, error: participationError } = await db .from('student_participations') .insert({ diff --git a/app/api/student/results/route.ts b/app/api/student/results/route.ts index a94ab54..433b691 100644 --- a/app/api/student/results/route.ts +++ b/app/api/student/results/route.ts @@ -1,11 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/server' export async function GET(request: NextRequest) { try { - const supabase = await createClient() - const db = supabase as any + const db = createAdminClient() as any const { searchParams } = new URL(request.url) const participationId = searchParams.get('participation_id') diff --git a/app/api/student/submit-answer/route.ts b/app/api/student/submit-answer/route.ts index aee0919..e6f9bfc 100644 --- a/app/api/student/submit-answer/route.ts +++ b/app/api/student/submit-answer/route.ts @@ -1,11 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/server' export async function POST(request: NextRequest) { try { - const supabase = await createClient() - const db = supabase as any + const db = createAdminClient() as any const body = await request.json() const { participation_id, question_id, answer_id } = body diff --git a/app/api/upload-quiz/route.ts b/app/api/upload-quiz/route.ts index 3e6dd57..7aba1af 100644 --- a/app/api/upload-quiz/route.ts +++ b/app/api/upload-quiz/route.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { QuizJsonFormat } from '@/lib/types/database' @@ -5,6 +6,7 @@ import { QuizJsonFormat } from '@/lib/types/database' export async function POST(request: NextRequest) { try { const supabase = await createClient() + const db = supabase as any const { data: { user }, error: authError } = await supabase.auth.getUser() if (authError || !user) { @@ -18,6 +20,9 @@ export async function POST(request: NextRequest) { if (!file) { return NextResponse.json({ error: 'Fichier manquant' }, { status: 400 }) } + if (!subchapterId) { + return NextResponse.json({ error: 'subchapter_id requis' }, { status: 400 }) + } const text = await file.text() let quizData: QuizJsonFormat @@ -35,13 +40,34 @@ export async function POST(request: NextRequest) { ) } - // Créer le quiz - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data: quiz, error: quizError } = await (supabase as any) + // Vérifier que le chapitre existe + const { data: subchapter, error: subError } = await db + .from('subchapters') + .select('id') + .eq('id', subchapterId) + .single() + + if (subError || !subchapter) { + return NextResponse.json({ error: 'Chapitre introuvable' }, { status: 404 }) + } + + // Si ce chapitre a déjà un quiz → le supprimer (cascade supprime questions + réponses) + const { data: existingQuiz } = await db + .from('quizzes') + .select('id') + .eq('subchapter_id', subchapterId) + .maybeSingle() + + if (existingQuiz) { + await db.from('quizzes').delete().eq('id', existingQuiz.id) + } + + // Créer le nouveau quiz + const { data: quiz, error: quizError } = await db .from('quizzes') .insert({ title: quizData.title, - subchapter_id: subchapterId ?? null, + subchapter_id: subchapterId, author_id: user.id, raw_json_data: quizData, }) @@ -60,8 +86,7 @@ export async function POST(request: NextRequest) { order: index, })) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data: insertedQuestions, error: questionsError } = await (supabase as any) + const { data: insertedQuestions, error: questionsError } = await db .from('questions') .insert(questionsToInsert) .select() @@ -83,10 +108,7 @@ export async function POST(request: NextRequest) { })) }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { error: answersError } = await (supabase as any) - .from('answers') - .insert(answersToInsert) + const { error: answersError } = await db.from('answers').insert(answersToInsert) if (answersError) { return NextResponse.json( @@ -100,7 +122,9 @@ export async function POST(request: NextRequest) { quiz: { id: quiz.id, title: quiz.title, + subchapter_id: subchapterId, questionCount: insertedQuestions.length, + replaced: !!existingQuiz, }, }) } catch (error) { diff --git a/app/dashboard/admin/users/UsersClient.tsx b/app/dashboard/admin/users/UsersClient.tsx new file mode 100644 index 0000000..ef15274 --- /dev/null +++ b/app/dashboard/admin/users/UsersClient.tsx @@ -0,0 +1,324 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { + Users, + Shield, + Plus, + Trash2, + ChevronDown, + Check, + Loader2, + Search, + X, + UserCircle, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import Link from 'next/link' + +interface User { + id: string + email: string + username: string + role: 'admin' | 'formateur' + created_at: string + last_sign_in: string | null +} + +interface Props { + initialUsers: User[] + currentUserId: string +} + +function formatDate(dateStr: string | null) { + if (!dateStr) return 'Jamais' + return new Date(dateStr).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + year: 'numeric', + }) +} + +const roleConfig = { + admin: { label: 'Admin', color: 'bg-purple-500/10 text-purple-400 border-purple-500/20' }, + formateur: { label: 'Formateur', color: 'bg-blue-500/10 text-blue-400 border-blue-500/20' }, +} + +export default function UsersClient({ initialUsers, currentUserId }: Props) { + const router = useRouter() + const [users, setUsers] = useState(initialUsers) + const [search, setSearch] = useState('') + const [filterRole, setFilterRole] = useState<'all' | 'admin' | 'formateur'>('all') + + const [changingRoleId, setChangingRoleId] = useState(null) + const [roleDropdownId, setRoleDropdownId] = useState(null) + const [confirmDeleteId, setConfirmDeleteId] = useState(null) + const [deletingId, setDeletingId] = useState(null) + const [error, setError] = useState(null) + + const filtered = users.filter((u) => { + const q = search.toLowerCase() + const matchSearch = !q || u.email.toLowerCase().includes(q) || u.username.toLowerCase().includes(q) + const matchRole = filterRole === 'all' || u.role === filterRole + return matchSearch && matchRole + }) + + const handleChangeRole = async (userId: string, newRole: 'admin' | 'formateur') => { + setChangingRoleId(userId) + setRoleDropdownId(null) + setError(null) + try { + const res = await fetch(`/api/admin/users/${userId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: newRole }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error) + } else { + setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role: newRole } : u))) + } + } finally { + setChangingRoleId(null) + } + } + + const handleDelete = async (userId: string) => { + setDeletingId(userId) + setError(null) + try { + const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) { + setError(data.error) + } else { + setUsers((prev) => prev.filter((u) => u.id !== userId)) + setConfirmDeleteId(null) + } + } finally { + setDeletingId(null) + } + } + + const adminCount = users.filter((u) => u.role === 'admin').length + const formateurCount = users.filter((u) => u.role === 'formateur').length + + return ( +
+ {/* Header */} +
+
+

Gestion des Utilisateurs

+

+ Créez et gérez les comptes formateurs et administrateurs de la plateforme. +

+
+ + + Créer un compte + Créer + +
+ + {error && ( +
+ {error} + +
+ )} + + {/* Stats */} +
+ {[ + { icon: Users, label: 'Total', value: users.length, color: 'text-text-secondary' }, + { icon: Shield, label: 'Admins', value: adminCount, color: 'text-purple-400' }, + { icon: UserCircle, label: 'Formateurs', value: formateurCount, color: 'text-blue-400' }, + ].map(({ icon: Icon, label, value, color }) => ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ))} +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + placeholder="Rechercher un utilisateur..." + className="input-field pl-9 py-2 text-sm w-full" + /> +
+
+ {(['all', 'admin', 'formateur'] as const).map((r) => ( + + ))} +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((user) => { + const isSelf = user.id === currentUserId + const roleCfg = roleConfig[user.role] ?? roleConfig.formateur + const isConfirmingDelete = confirmDeleteId === user.id + const isDeleting = deletingId === user.id + const isChanging = changingRoleId === user.id + + return ( + + {/* Avatar + Username */} + + + {/* Email */} + + + {/* Rôle (dropdown) */} + + + {/* Created at */} + + + {/* Last sign in */} + + + {/* Actions */} + + + ) + }) + )} + +
UtilisateurEmailRôleCréé leDernière connexionActions
+ Aucun utilisateur trouvé +
+
+
+ {user.username.slice(0, 2).toUpperCase()} +
+
+

{user.username}

+ {isSelf &&

Vous

} +
+
+
{user.email} +
+ + + {roleDropdownId === user.id && ( +
+ {(['formateur', 'admin'] as const).map((r) => ( + + ))} +
+ )} +
+
{formatDate(user.created_at)}{formatDate(user.last_sign_in)} +
+ {isConfirmingDelete ? ( +
+ Supprimer ? + + +
+ ) : ( + + )} +
+
+
+ + {/* Clic en dehors ferme les dropdowns */} + {roleDropdownId && ( +
setRoleDropdownId(null)} /> + )} +
+ ) +} diff --git a/app/dashboard/admin/users/create/page.tsx b/app/dashboard/admin/users/create/page.tsx new file mode 100644 index 0000000..a1d4404 --- /dev/null +++ b/app/dashboard/admin/users/create/page.tsx @@ -0,0 +1,256 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { + Mail, + Lock, + User, + Shield, + Eye, + EyeOff, + Loader2, + ArrowLeft, + RefreshCw, + Check, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +function generatePassword() { + const chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#$' + return Array.from({ length: 12 }, () => chars[Math.floor(Math.random() * chars.length)]).join('') +} + +export default function CreateUserPage() { + const router = useRouter() + const [email, setEmail] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState(generatePassword()) + const [role, setRole] = useState<'formateur' | 'admin'>('formateur') + const [showPassword, setShowPassword] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + try { + const res = await fetch('/api/admin/users/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, username, password, role }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error) + } else { + setSuccess(true) + setTimeout(() => router.push('/dashboard/admin/users'), 1500) + } + } catch { + setError('Erreur réseau') + } finally { + setLoading(false) + } + } + + if (success) { + return ( +
+
+
+ +
+

Compte créé !

+

Redirection en cours...

+
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+ + + Retour à la liste + +

Créer un compte

+

+ Le nouveau membre pourra se connecter immédiatement avec ses identifiants. +

+
+ +
+
+ {/* Identité */} +
+
+ +

Identité

+
+ +
+
+ +
+ + setUsername(e.target.value)} + placeholder="Ex: jean.dupont" + className="input-field pl-9" + required + /> +
+
+ +
+ +
+ + setEmail(e.target.value)} + placeholder="Ex: jean.dupont@solyti.fr" + className="input-field pl-9" + required + /> +
+
+
+
+ + {/* Rôle */} +
+
+ +

Rôle

+
+ +
+ {([ + { + value: 'formateur', + label: 'Formateur', + desc: 'Gère ses quiz et sessions', + icon: '👨‍🏫', + }, + { + value: 'admin', + label: 'Administrateur', + desc: 'Accès complet + gestion des comptes', + icon: '🛡️', + }, + ] as const).map((r) => ( + + ))} +
+
+ + {/* Mot de passe */} +
+
+ +

Mot de passe temporaire

+
+ +
+ +
+ + setPassword(e.target.value)} + className="input-field pl-9 pr-20" + required + minLength={8} + /> +
+ + +
+
+

+ Communiquez ce mot de passe à l'utilisateur. Il pourra le modifier depuis ses paramètres. +

+
+
+ + {/* Footer */} +
+ + Annuler + +
+ {error &&

{error}

} + +
+
+
+
+
+
+ ) +} diff --git a/app/dashboard/admin/users/page.tsx b/app/dashboard/admin/users/page.tsx new file mode 100644 index 0000000..a0a5e9a --- /dev/null +++ b/app/dashboard/admin/users/page.tsx @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/server' +import UsersClient from './UsersClient' + +export default async function AdminUsersPage() { + const supabase = await createClient() + const db = supabase as any + + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: callerProfile } = await db + .from('profiles') + .select('role') + .eq('id', user.id) + .single() + + if (callerProfile?.role !== 'admin') redirect('/dashboard') + + // Utilise le client admin pour bypass RLS + const admin = createAdminClient() + const adminDb = admin as any + + // Récupérer les données auth via la fonction SECURITY DEFINER (évite listUsers défaillant) + const [authResult, profilesResult] = await Promise.all([ + adminDb.rpc('get_all_users_admin'), + adminDb.from('profiles').select('id, username, role, created_at').order('created_at', { ascending: false }), + ]) + + const profileMap = new Map((profilesResult.data ?? []).map((p: any) => [p.id, p])) + + const users = (authResult.data ?? []).map((authUser: any) => { + const profile: any = profileMap.get(authUser.id) ?? {} + return { + id: authUser.id, + email: authUser.email ?? '', + username: profile.username ?? authUser.email?.split('@')[0] ?? '—', + role: profile.role ?? 'formateur', + created_at: profile.created_at ?? authUser.created_at, + last_sign_in: authUser.last_sign_in_at ?? null, + } + }) + + return +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 98e4083..4745bbf 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -26,9 +26,10 @@ export default async function DashboardLayout({
-
+
{children}
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 03441be..78848a0 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -27,7 +27,7 @@ function KpiCard({ icon: React.ElementType }) { return ( -
+
{label}
@@ -56,8 +56,8 @@ function SessionCard({ session }: { session: any }) { const status = statusConfig[statusKey] ?? statusConfig.completed return ( -
-
+
+
@@ -132,13 +132,16 @@ export default async function DashboardPage() { .select('id', { count: 'exact', head: true }) .eq('trainer_id', user!.id) - const sessionIds = (sessions ?? []).map((s: any) => s.id) + const activeSessionIds = (sessions ?? []) + .filter((s: any) => s.is_active) + .map((s: any) => s.id) - const { count: totalParticipants } = sessionIds.length > 0 + const { count: totalParticipants } = activeSessionIds.length > 0 ? await db .from('student_participations') .select('id', { count: 'exact', head: true }) - .in('session_id', sessionIds) + .in('session_id', activeSessionIds) + .eq('status', 'in_progress') : { count: 0 } const displayName = profile?.username ?? user?.email?.split('@')[0] ?? 'Formateur' @@ -157,11 +160,11 @@ export default async function DashboardPage() { })) return ( -
-
+
+
-

Dashboard Overview

-

Bienvenue, {displayName}. Voici ce qui se passe aujourd'hui.

+

Dashboard Overview

+

Bienvenue, {displayName}. Voici ce qui se passe aujourd'hui.

@@ -171,9 +174,9 @@ export default async function DashboardPage() {
-
+
- +
@@ -200,7 +203,7 @@ export default async function DashboardPage() {
-
+

Notifications Récentes

diff --git a/app/dashboard/quizzes/QuizzesClient.tsx b/app/dashboard/quizzes/QuizzesClient.tsx index 6a20f84..610ab1e 100644 --- a/app/dashboard/quizzes/QuizzesClient.tsx +++ b/app/dashboard/quizzes/QuizzesClient.tsx @@ -17,6 +17,8 @@ import { Check, X, Loader2, + FileJson, + RefreshCw, } from 'lucide-react' import { cn } from '@/lib/utils' @@ -64,24 +66,26 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } new Set(initialCategories.slice(0, 1).map((c) => c.id)) ) const [search, setSearch] = useState('') - const [uploading, setUploading] = useState(false) + + // Upload state — clé = subchapter_id + const [uploadingSubchapterId, setUploadingSubchapterId] = useState(null) const [uploadError, setUploadError] = useState(null) const fileInputRef = useRef(null) + const [pendingSubchapterId, setPendingSubchapterId] = useState(null) - // --- Category CRUD state --- + // Category CRUD const [showNewCategoryModal, setShowNewCategoryModal] = useState(false) const [newCategoryName, setNewCategoryName] = useState('') const [newCategoryDesc, setNewCategoryDesc] = useState('') const [creatingCategory, setCreatingCategory] = useState(false) const [createCategoryError, setCreateCategoryError] = useState(null) - const [editingCategoryId, setEditingCategoryId] = useState(null) const [editingCategoryName, setEditingCategoryName] = useState('') const [savingCategoryId, setSavingCategoryId] = useState(null) const [confirmDeleteCategoryId, setConfirmDeleteCategoryId] = useState(null) const [deletingCategoryId, setDeletingCategoryId] = useState(null) - // --- Subchapter CRUD state --- + // Subchapter CRUD const [editingSubchapterId, setEditingSubchapterId] = useState(null) const [editingSubchapterName, setEditingSubchapterName] = useState('') const [savingSubchapterId, setSavingSubchapterId] = useState(null) @@ -109,27 +113,63 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } ) }) + // ── Upload quiz pour un chapitre spécifique ── + const triggerUpload = (subchapterId: string) => { + setPendingSubchapterId(subchapterId) + setUploadError(null) + fileInputRef.current?.click() + } + const handleFileUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] - if (!file) return - setUploading(true) + if (!file || !pendingSubchapterId) return + + const subchapterId = pendingSubchapterId + setUploadingSubchapterId(subchapterId) setUploadError(null) + const formData = new FormData() formData.append('file', file) + formData.append('subchapter_id', subchapterId) + try { const res = await fetch('/api/upload-quiz', { method: 'POST', body: formData }) const data = await res.json() - if (!res.ok) setUploadError(data.error ?? "Erreur lors de l'import") - else router.refresh() + if (!res.ok) { + setUploadError(data.error ?? "Erreur lors de l'import") + } else { + // Mettre à jour le chapitre dans le state local + const newQuiz: Quiz = { + id: data.quiz.id, + title: data.quiz.title, + updated_at: new Date().toISOString(), + } + setCategories((prev) => + prev.map((cat) => ({ + ...cat, + subchapters: cat.subchapters.map((sub) => + sub.id === subchapterId + ? { ...sub, quizzes: [newQuiz] } + : sub + ), + })) + ) + if (data.quiz.replaced) { + // Compte inchangé (remplacement) + } else { + setStats((s) => ({ ...s, totalQuizzes: s.totalQuizzes + 1 })) + } + } } catch { setUploadError('Erreur réseau') } finally { - setUploading(false) + setUploadingSubchapterId(null) + setPendingSubchapterId(null) if (fileInputRef.current) fileInputRef.current.value = '' } } - // --- Category handlers --- + // ── Category handlers ── const handleCreateCategory = async () => { if (!newCategoryName.trim()) return setCreatingCategory(true) @@ -159,12 +199,6 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } } } - const startEditCategory = (cat: Category) => { - setEditingCategoryId(cat.id) - setEditingCategoryName(cat.name) - setConfirmDeleteCategoryId(null) - } - const handleSaveCategory = async (id: string) => { if (!editingCategoryName.trim()) return setSavingCategoryId(id) @@ -191,7 +225,6 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } const res = await fetch(`/api/categories/${id}`, { method: 'DELETE' }) if (res.ok) { const cat = categories.find((c) => c.id === id) - const removedSubchapters = cat?.subchapters.length ?? 0 const removedQuizzes = cat?.subchapters.reduce((acc, s) => acc + s.quizzes.length, 0) ?? 0 setCategories((prev) => prev.filter((c) => c.id !== id)) setStats((s) => ({ @@ -206,13 +239,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } } } - // --- Subchapter handlers --- - const startEditSubchapter = (sub: Subchapter) => { - setEditingSubchapterId(sub.id) - setEditingSubchapterName(sub.name) - setConfirmDeleteSubchapterId(null) - } - + // ── Subchapter handlers ── const handleSaveSubchapter = async (subId: string) => { if (!editingSubchapterName.trim()) return setSavingSubchapterId(subId) @@ -284,87 +311,75 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } } } - const handlePlaySubchapter = (subchapter: Subchapter) => { - const firstQuiz = subchapter.quizzes[0] - if (firstQuiz) { - router.push(`/dashboard/sessions/create?quiz_id=${firstQuiz.id}`) - } else { - router.push('/dashboard/sessions/create') - } - } - const categoryIcons = ['🔴', '🔵', '🟢', '🟡', '🟣', '🟠'] return ( -
+
+ {/* Input file caché — partagé, déclenché par triggerUpload() */} + + {/* Header */} -
+
-

Gestion des Quiz

-

- Organisez votre curriculum, gérez les catégories et mettez à jour les contenus pédagogiques. +

Gestion des Quiz

+

+ Organisez votre curriculum, gérez les catégories et importez les quiz dans chaque chapitre.

-
- - -
+
{uploadError && ( -
+
{uploadError} +
)} {/* Stats */} -
+
{[ { icon: BookOpen, label: 'Total Quiz', value: stats.totalQuizzes, color: 'text-green-400' }, { icon: Grid, label: 'Catégories', value: stats.totalCategories, color: 'text-blue-400' }, { icon: Users, label: 'Étudiants Actifs', value: stats.activeStudents, color: 'text-purple-400' }, ].map(({ icon: Icon, label, value, color }) => ( -
-
- +
+
+ +
-

{label}

-

{value}

+

{label}

+

{value}

))}
- {/* Categories Section */} + {/* Categories */}
-
+

Catégories Actives

-
+
setSearch(e.target.value)} placeholder="Rechercher un chapitre..." - className="input-field pl-9 py-2 text-sm w-64" + className="input-field pl-9 py-2 text-sm w-full" />
@@ -422,61 +437,31 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } disabled={savingCategoryId === category.id} className="p-1.5 bg-primary/10 hover:bg-primary/20 rounded-md text-primary transition-colors" > - {savingCategoryId === category.id ? ( - - ) : ( - - )} + {savingCategoryId === category.id ? : } - ) : isConfirmingDelete ? (
Supprimer ? - - +
) : ( <> - - Publié - - - )} @@ -485,8 +470,8 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } {/* Subchapters table */} {isExpanded && ( -
- +
+
@@ -500,9 +485,12 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } const isEditingSub = editingSubchapterId === subchapter.id const isConfirmingDeleteSub = confirmDeleteSubchapterId === subchapter.id const isDeletingSub = deletingSubchapterId === subchapter.id + const isUploading = uploadingSubchapterId === subchapter.id + const quiz = subchapter.quizzes[0] ?? null return ( + {/* Nom du chapitre */} - + + {/* Date */} + + {/* Actions */}
Chapitre
{isEditingSub ? ( ) : ( subchapter.name )} - {subchapter.quizzes.length} Quiz + + {/* Quiz associé */} + + {quiz ? ( + {quiz.title} + ) : ( + Aucun quiz + )} - {subchapter.quizzes[0] ? formatDate(subchapter.quizzes[0].updated_at) : '—'} + {quiz ? formatDate(quiz.updated_at) : '—'}
{isEditingSub ? ( <> - - ) : isConfirmingDeleteSub ? (
Supprimer ? - - +
) : ( <> + {/* Renommer le chapitre */} + + {/* Éditer le quiz (si existant) */} + {quiz && ( + + )} + + {/* Importer / Remplacer le quiz */} + + {/* Lancer une session */} + + + {/* Supprimer le chapitre */} @@ -596,7 +617,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } ) })} - {/* Add subchapter row */} + {/* Ajouter un chapitre */} {addingSubchapterForCategoryId === category.id && (
@@ -612,17 +633,10 @@ export default function QuizzesClient({ initialCategories, stats: initialStats } className="input-field text-sm py-1.5 flex-1 max-w-xs" autoFocus /> - - @@ -634,11 +648,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
- {/* New Category Modal */} + {/* Légende des icônes */} +
+
Renommer
+
Éditer le quiz
+
Importer JSON
+
Remplacer (JSON)
+
Lancer session
+
Supprimer
+
+ + {/* Modale nouvelle catégorie */} {showNewCategoryModal && (

Nouvelle Catégorie

-
- setNewCategoryName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleCreateCategory()} - placeholder="Ex: Développement Web" - className="input-field" - autoFocus - /> + setNewCategoryName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleCreateCategory()} placeholder="Ex: Développement Web" className="input-field" autoFocus />
-