ajoute le portail Laravel : connexion prof/élève, session unique, gestion de classe, salle d'attente

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-08 02:01:58 +02:00
parent aa31b6112f
commit 30e14b1c54
191 changed files with 25324 additions and 0 deletions

View file

@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Eleve;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class ConnexionController extends Controller
{
public function connexionProf(Request $request): RedirectResponse
{
$donnees = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
$this->appliquerLimiteTentatives($request, 'prof:'.$donnees['email']);
if (! Auth::guard('web')->attempt($donnees)) {
RateLimiter::hit($this->cleLimite($request, 'prof:'.$donnees['email']));
throw ValidationException::withMessages([
'email' => 'Identifiants incorrects.',
]);
}
RateLimiter::clear($this->cleLimite($request, 'prof:'.$donnees['email']));
$request->session()->regenerate();
/** @var User $prof */
$prof = Auth::guard('web')->user();
$avaitDejaUneSession = $prof->jeton_session_actif !== null;
$request->session()->put('jeton_session_web', $prof->demarreNouvelleSession());
return redirect()->route('mes-classes.index')
->with('session_precedente_fermee', $avaitDejaUneSession);
}
public function connexionEleve(Request $request): RedirectResponse
{
$donnees = $request->validate([
'identifiant' => ['required', 'string'],
'code' => ['required', 'string'],
]);
$this->appliquerLimiteTentatives($request, 'eleve:'.$donnees['identifiant']);
$succes = Auth::guard('eleve')->attempt([
'identifiant' => $donnees['identifiant'],
'password' => $donnees['code'],
]);
if (! $succes) {
RateLimiter::hit($this->cleLimite($request, 'eleve:'.$donnees['identifiant']));
throw ValidationException::withMessages([
'identifiant' => 'Identifiant ou code secret incorrect.',
]);
}
RateLimiter::clear($this->cleLimite($request, 'eleve:'.$donnees['identifiant']));
$request->session()->regenerate();
/** @var Eleve $eleve */
$eleve = Auth::guard('eleve')->user();
$eleve->forceFill(['derniere_connexion_a' => now()])->save();
$request->session()->put('jeton_session_eleve', $eleve->demarreNouvelleSession());
return redirect()->route('eleve');
}
/**
* Connexion directe par QR (à usage unique, voir App\Services\GenerateurCompteEleve).
*/
public function connexionParQr(Request $request, string $jeton): RedirectResponse
{
$eleve = Eleve::where('jeton_qr', $jeton)->first();
if ($eleve === null) {
abort(404);
}
$eleve->forceFill(['jeton_qr' => null, 'derniere_connexion_a' => now()])->save();
Auth::guard('eleve')->login($eleve);
$request->session()->regenerate();
$request->session()->put('jeton_session_eleve', $eleve->demarreNouvelleSession());
return redirect()->route('eleve');
}
public function deconnexion(Request $request): RedirectResponse
{
if (Auth::guard('web')->check()) {
Auth::guard('web')->user()->terminerSession();
Auth::guard('web')->logout();
}
if (Auth::guard('eleve')->check()) {
Auth::guard('eleve')->user()->terminerSession();
Auth::guard('eleve')->logout();
}
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('accueil');
}
private function cleLimite(Request $request, string $identifiant): string
{
return 'connexion:'.$identifiant.'|'.$request->ip();
}
private function appliquerLimiteTentatives(Request $request, string $identifiant): void
{
$cle = $this->cleLimite($request, $identifiant);
if (RateLimiter::tooManyAttempts($cle, 5)) {
$secondes = RateLimiter::availableIn($cle);
throw ValidationException::withMessages([
'identifiant' => "Trop de tentatives. Réessaie dans {$secondes} secondes.",
]);
}
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\Eleve;
use App\Services\GenerateurCompteEleve;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\View\View;
class ClasseController extends Controller
{
public function __construct(private readonly GenerateurCompteEleve $generateur) {}
public function index(): View
{
$classes = Auth::guard('web')->user()->classes()->withCount('eleves')->latest()->get();
return view('mes-classes.index', ['classes' => $classes]);
}
public function show(Classe $classe): View
{
$this->verifierProprietaire($classe);
return view('mes-classes.show', [
'classe' => $classe->load('eleves'),
]);
}
public function storeEleve(Request $request, Classe $classe): RedirectResponse
{
$this->verifierProprietaire($classe);
$donnees = $request->validate([
'prenom' => ['required', 'string', 'max:100'],
'nom' => ['nullable', 'string', 'max:100'],
]);
$fiche = $this->creerEleve($classe, $donnees['prenom'], $donnees['nom'] ?? null);
return redirect()->route('mes-classes.show', $classe)
->with('fiches', [$fiche]);
}
public function importerCsv(Request $request, Classe $classe): RedirectResponse
{
$this->verifierProprietaire($classe);
$request->validate([
'fichier' => ['required', 'file', 'mimes:csv,txt'],
]);
$lignes = file($request->file('fichier')->getRealPath(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$fiches = [];
foreach ($lignes as $ligne) {
$colonnes = str_getcsv($ligne);
$prenom = trim($colonnes[0] ?? '');
if ($prenom === '' || Str::lower($prenom) === 'prenom') {
continue; // ligne vide ou en-tête
}
$nom = trim($colonnes[1] ?? '') ?: null;
$fiches[] = $this->creerEleve($classe, $prenom, $nom);
}
return redirect()->route('mes-classes.show', $classe)
->with('fiches', $fiches)
->with('nombre_importes', count($fiches));
}
public function regenererCode(Classe $classe, Eleve $eleve): RedirectResponse
{
$this->verifierProprietaire($classe);
abort_if($eleve->classe_id !== $classe->id, 404);
$codeSecret = $this->generateur->genererCodeSecret();
$jetonQr = Str::random(32);
$eleve->forceFill([
'code_secret' => Hash::make($codeSecret),
'jeton_qr' => $jetonQr,
])->save();
return redirect()->route('mes-classes.show', $classe)
->with('fiches', [[
'identifiant' => $eleve->identifiant,
'prenom' => $eleve->prenom,
'nom' => $eleve->nom,
'code_secret' => $codeSecret,
'jeton_qr' => $jetonQr,
]]);
}
/**
* @return array{identifiant: string, prenom: string, nom: ?string, code_secret: string, jeton_qr: string}
*/
private function creerEleve(Classe $classe, string $prenom, ?string $nom): array
{
$identifiant = $this->generateur->genererIdentifiant($prenom, $nom);
$codeSecret = $this->generateur->genererCodeSecret();
$jetonQr = Str::random(32);
$classe->eleves()->create([
'prenom' => $prenom,
'nom' => $nom,
'identifiant' => $identifiant,
'code_secret' => Hash::make($codeSecret),
'jeton_qr' => $jetonQr,
]);
return [
'identifiant' => $identifiant,
'prenom' => $prenom,
'nom' => $nom,
'code_secret' => $codeSecret,
'jeton_qr' => $jetonQr,
];
}
private function verifierProprietaire(Classe $classe): void
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class EleveTableauDeBordController extends Controller
{
public function show(): View
{
$eleve = Auth::guard('eleve')->user()->load('classe');
return view('eleve', ['eleve' => $eleve]);
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\Etablissement;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class InscriptionController extends Controller
{
public function store(Request $request): RedirectResponse
{
$donnees = $request->validate([
'prenom' => ['required', 'string', 'max:100'],
'nom' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'etablissement_nom_liste' => ['required', 'string'],
'etablissement_nom' => ['required_if:etablissement_nom_liste,autre', 'nullable', 'string', 'max:150'],
'etablissement_ville' => ['required_if:etablissement_nom_liste,autre', 'nullable', 'string', 'max:100'],
'etablissement_uai' => ['nullable', 'string', 'max:20'],
'formation' => ['nullable', 'string', 'max:100'],
'classe' => ['required', 'string', 'max:100'],
]);
[$prof, $classe] = DB::transaction(function () use ($donnees) {
$etablissement = $donnees['etablissement_nom_liste'] === 'autre'
? Etablissement::firstOrCreate(
['nom' => $donnees['etablissement_nom']],
['ville' => $donnees['etablissement_ville'], 'uai' => $donnees['etablissement_uai'] ?? null],
)
: Etablissement::firstOrCreate(['nom' => $donnees['etablissement_nom_liste']]);
$prof = User::create([
'name' => trim($donnees['prenom'].' '.$donnees['nom']),
'email' => $donnees['email'],
'password' => Hash::make($donnees['password']),
'etablissement_id' => $etablissement->id,
]);
$classe = Classe::create([
'etablissement_id' => $etablissement->id,
'user_id' => $prof->id,
'nom' => $donnees['classe'],
'formation' => $donnees['formation'] ?? null,
]);
return [$prof, $classe];
});
Auth::guard('web')->login($prof);
$request->session()->regenerate();
$request->session()->put('jeton_session_web', $prof->demarreNouvelleSession());
return redirect()->route('mes-classes.show', $classe)
->with('classe_creee', true);
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\LabSession;
use App\Models\Seance;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class SeanceController extends Controller
{
public function store(Request $request, Classe $classe): RedirectResponse
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
$donnees = $request->validate([
'lab_id' => ['required', 'exists:labs,id'],
]);
$seance = $classe->seances()->create([
'lab_id' => $donnees['lab_id'],
'statut' => Seance::SALLE_ATTENTE,
'demarree_a' => now(),
]);
return redirect()->route('seances.attente', $seance);
}
public function attente(Seance $seance): View
{
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
return view('seances.attente', [
'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve']),
]);
}
public function admettre(Seance $seance, LabSession $labSession): RedirectResponse
{
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
abort_if($labSession->seance_id !== $seance->id, 404);
$labSession->update(['etat' => LabSession::ADMIS]);
return redirect()->route('seances.attente', $seance);
}
/**
* Un élève rejoint la séance : il patiente jusqu'à ce que le prof l'admette.
*/
public function rejoindre(Request $request, Seance $seance): RedirectResponse
{
$eleve = Auth::guard('eleve')->user();
abort_if($eleve->classe_id !== $seance->classe_id, 403);
LabSession::firstOrCreate(
['seance_id' => $seance->id, 'eleve_id' => $eleve->id],
['lab_id' => $seance->lab_id, 'etat' => LabSession::EN_ATTENTE],
);
return redirect()->route('seances.eleve.attente', $seance);
}
public function attenteEleve(Seance $seance): View|RedirectResponse
{
$eleve = Auth::guard('eleve')->user();
abort_if($eleve->classe_id !== $seance->classe_id, 403);
$labSession = LabSession::where('seance_id', $seance->id)
->where('eleve_id', $eleve->id)
->first();
if ($labSession === null) {
return redirect()->route('eleve');
}
return view('seances.attente-eleve', [
'seance' => $seance->load('lab'),
'labSession' => $labSession,
]);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Une seule session active par compte (étape 8). Si le jeton que porte cette
* session ne correspond plus à celui enregistré sur le compte, c'est qu'une
* connexion plus récente a eu lieu ailleurs : on déconnecte et on le dit.
*/
class VerifieSessionUnique
{
public function handle(Request $request, Closure $next, string $garde = 'web'): Response
{
$utilisateur = Auth::guard($garde)->user();
if ($utilisateur !== null) {
$cleSession = "jeton_session_{$garde}";
if (! $utilisateur->sessionEstEncoreValide($request->session()->get($cleSession))) {
Auth::guard($garde)->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')
->with('session_coupee', 'Vous avez été déconnecté : une nouvelle connexion a eu lieu sur votre compte.');
}
}
return $next($request);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class Classe extends Model
{
use HasFactory;
protected $fillable = ['etablissement_id', 'user_id', 'nom', 'formation', 'code_classe'];
protected $casts = [
'archivee_a' => 'datetime',
];
protected static function booted(): void
{
static::creating(function (Classe $classe) {
$classe->code_classe ??= static::genererCodeUnique();
});
}
public static function genererCodeUnique(): string
{
do {
$code = Str::upper(Str::random(6));
} while (static::where('code_classe', $code)->exists());
return $code;
}
public function etablissement(): BelongsTo
{
return $this->belongsTo(Etablissement::class);
}
public function prof(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function eleves(): HasMany
{
return $this->hasMany(Eleve::class);
}
public function seances(): HasMany
{
return $this->hasMany(Seance::class);
}
public function estArchivee(): bool
{
return $this->archivee_a !== null;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Str;
/**
* Une seule session active par compte (étape 8) : la précédente est coupée
* et son porteur en est averti à sa prochaine requête (voir
* App\Http\Middleware\VerifieSessionUnique).
*/
trait GereSessionUnique
{
public function demarreNouvelleSession(): string
{
$jeton = Str::random(40);
$this->forceFill(['jeton_session_actif' => $jeton])->save();
return $jeton;
}
public function sessionEstEncoreValide(?string $jeton): bool
{
return $jeton !== null && $jeton === $this->jeton_session_actif;
}
public function terminerSession(): void
{
$this->forceFill(['jeton_session_actif' => null])->save();
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Models\Concerns\GereSessionUnique;
use Illuminate\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Eleve extends Model implements AuthenticatableContract
{
use Authenticatable, GereSessionUnique, HasFactory;
protected $fillable = ['classe_id', 'prenom', 'nom', 'identifiant', 'code_secret', 'jeton_qr'];
protected $hidden = ['code_secret', 'jeton_session_actif', 'jeton_qr'];
protected $casts = [
'derniere_connexion_a' => 'datetime',
];
public function getAuthPasswordName(): string
{
return 'code_secret';
}
public function classe(): BelongsTo
{
return $this->belongsTo(Classe::class);
}
public function labSessions(): HasMany
{
return $this->hasMany(LabSession::class);
}
public function nomAffiche(): string
{
return $this->nom
? $this->prenom.' '.mb_substr($this->nom, 0, 1).'.'
: $this->prenom;
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Etablissement extends Model
{
use HasFactory;
protected $fillable = ['nom', 'ville', 'uai'];
public function profs(): HasMany
{
return $this->hasMany(User::class);
}
public function classes(): HasMany
{
return $this->hasMany(Classe::class);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Lab extends Model
{
use HasFactory;
protected $fillable = ['identifiant', 'titre', 'niveau', 'duree_min'];
public function seances(): HasMany
{
return $this->hasMany(Seance::class);
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LabSession extends Model
{
use HasFactory;
public const EN_ATTENTE = 'en_attente';
public const ADMIS = 'admis';
public const ACTIF = 'actif';
public const TERMINE = 'termine';
public const EXPIRE = 'expire';
protected $fillable = ['seance_id', 'eleve_id', 'lab_id', 'stack_id', 'etat', 'expire_a'];
protected $casts = [
'expire_a' => 'datetime',
];
public function seance(): BelongsTo
{
return $this->belongsTo(Seance::class);
}
public function eleve(): BelongsTo
{
return $this->belongsTo(Eleve::class);
}
public function lab(): BelongsTo
{
return $this->belongsTo(Lab::class);
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Seance extends Model
{
use HasFactory;
public const PLANIFIEE = 'planifiee';
public const SALLE_ATTENTE = 'salle_attente';
public const EN_COURS = 'en_cours';
public const TERMINEE = 'terminee';
protected $fillable = ['classe_id', 'lab_id', 'statut', 'demarree_a', 'terminee_a'];
protected $casts = [
'demarree_a' => 'datetime',
'terminee_a' => 'datetime',
];
public function classe(): BelongsTo
{
return $this->belongsTo(Classe::class);
}
public function lab(): BelongsTo
{
return $this->belongsTo(Lab::class);
}
public function labSessions(): HasMany
{
return $this->hasMany(LabSession::class);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Models\Concerns\GereSessionUnique;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password', 'etablissement_id'])]
#[Hidden(['password', 'remember_token', 'jeton_session_actif'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use GereSessionUnique, HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function etablissement(): BelongsTo
{
return $this->belongsTo(Etablissement::class);
}
public function classes(): HasMany
{
return $this->hasMany(Classe::class);
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Services;
use App\Models\Eleve;
use Illuminate\Support\Str;
/**
* Génère les identifiants (prénom.initiale, avec un numéro pour les
* homonymes) et les codes secrets des élèves (étape 8 du déploiement).
*/
class GenerateurCompteEleve
{
// Sans 0/O ni 1/I/L, pour qu'un élève ne confonde pas à la lecture.
private const ALPHABET_CODE = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
public function genererIdentifiant(string $prenom, ?string $nom): string
{
$base = Str::slug($prenom, '');
if ($nom !== null && $nom !== '') {
$base .= '.'.Str::slug(mb_substr($nom, 0, 1));
}
$identifiant = $base;
$suffixe = 2;
while (Eleve::where('identifiant', $identifiant)->exists()) {
$identifiant = $base.$suffixe;
$suffixe++;
}
return $identifiant;
}
public function genererCodeSecret(): string
{
$code = '';
for ($i = 0; $i < 6; $i++) {
$code .= self::ALPHABET_CODE[random_int(0, strlen(self::ALPHABET_CODE) - 1)];
}
return $code;
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Support;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
/**
* QR code en SVG (pas de dépendance Imagick/GD), pour les fiches de
* connexion imprimables de l'étape 8.
*/
class QrCode
{
public static function svg(string $contenu, int $taille = 180): string
{
$rendu = new ImageRenderer(new RendererStyle($taille), new SvgImageBackEnd);
return (new Writer($rendu))->writeString($contenu);
}
}