permet aux élèves de rejoindre leur classe avec son code, et les fiches en lot
L'élève saisit le code de classe et son prénom, le prof accepte, l'élève voit une seule fois son identifiant et son code. Aucun e-mail. Le prof peut aussi régénérer les fiches de plusieurs élèves d'un coup. Procédure pour prouver la copie de la clé des sauvegardes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1ac8d4773a
commit
dd4f9c3c55
21 changed files with 573 additions and 26 deletions
|
|
@ -25,3 +25,5 @@ retirer la ligne (et le noter dans le journal) quand c'est fait.
|
||||||
| Slogans « du lycée au master » | Le catalogue va aujourd'hui du lycée au BUT. Positionnement laissé tel quel : décision éditoriale d'Adam. | À décider | Étape 14 |
|
| Slogans « du lycée au master » | Le catalogue va aujourd'hui du lycée au BUT. Positionnement laissé tel quel : décision éditoriale d'Adam. | À décider | Étape 14 |
|
||||||
| Ancien `storage/logs/laravel.log` | Fichier unique d'avant la rotation quotidienne (étape 14) : jamais purgé, à supprimer ou archiver (accord d'Adam). | Dès que possible | Étape 14 |
|
| Ancien `storage/logs/laravel.log` | Fichier unique d'avant la rotation quotidienne (étape 14) : jamais purgé, à supprimer ou archiver (accord d'Adam). | Dès que possible | Étape 14 |
|
||||||
| Pages d'aperçu publiques (32) | Signalées comme aperçus avec données fictives ; à retirer ou remplacer au fur et à mesure que les fonctions réelles arrivent. | Au fil du bloc B | Étape 14 |
|
| Pages d'aperçu publiques (32) | Signalées comme aperçus avec données fictives ; à retirer ou remplacer au fur et à mesure que les fonctions réelles arrivent. | Au fil du bloc B | Étape 14 |
|
||||||
|
| Invitation des élèves majeurs par e-mail (V2 du CLAUDE.md) | Demande un service d'envoi d'e-mails (compte ouvert par Adam, DNS SPF/DKIM). Jamais pour les mineurs. | V2 | 2026-10-09 |
|
||||||
|
| Code de classe : caractères ambigus | Généré avec `Str::random` (0/O, 1/I/l possibles), contrairement aux codes élèves. Utiliser le même alphabet sans ambiguïté. | Bloc B | 2026-10-09 |
|
||||||
|
|
|
||||||
|
|
@ -612,3 +612,16 @@ Adam a ouvert `/ide` et transmis la console du navigateur, plus sa fiche INPI.
|
||||||
- **Mentions légales complétées** d'après la fiche INPI : Adam Belkacemi, entrepreneur individuel (EI), nom commercial Esquisse Formation, SIREN 838 093 268, SIRET 838 093 268 00087, 30 rue Lorenzaccio, 38100 Grenoble. La date de naissance de la fiche n'est pas reprise. Restent à compléter : l'article « Disponibilité et responsabilité » des CGU, et un éventuel délégué à la protection des données.
|
- **Mentions légales complétées** d'après la fiche INPI : Adam Belkacemi, entrepreneur individuel (EI), nom commercial Esquisse Formation, SIREN 838 093 268, SIRET 838 093 268 00087, 30 rue Lorenzaccio, 38100 Grenoble. La date de naissance de la fiche n'est pas reprise. Restent à compléter : l'article « Disponibilité et responsabilité » des CGU, et un éventuel délégué à la protection des données.
|
||||||
|
|
||||||
Vérifié en production : `/ide` 1 script en ligne, 0 erreur de syntaxe, `sandbox="allow-scripts"` ; Mulish déclarée par le portail ; aucun `@import` vers Google ; `/vendor/xterm/xterm.js` et `/vendor/codemirror/codemirror.js` en 200. 35 tests Pest.
|
Vérifié en production : `/ide` 1 script en ligne, 0 erreur de syntaxe, `sandbox="allow-scripts"` ; Mulish déclarée par le portail ; aucun `@import` vers Google ; `/vendor/xterm/xterm.js` et `/vendor/codemirror/codemirror.js` en 200. 35 tests Pest.
|
||||||
|
|
||||||
|
## 2026-10-09 — Accès des élèves : rejoindre la classe avec son code, fiches en lot
|
||||||
|
|
||||||
|
**Question d'Adam** : comment l'élève obtient-il un accès après sa création ? Faut-il un e-mail automatique pour créer un mot de passe ?
|
||||||
|
|
||||||
|
**Réponse et décision** : pas d'e-mail. Le CLAUDE.md l'interdit pour les mineurs (minimisation) et range l'invitation par e-mail en V2 (pour les majeurs) ; le serveur n'a pas non plus de service d'envoi (il faudrait un compte chez un prestataire, ouvert par Adam). Le parcours existant (identifiant + code secret + QR à usage unique, sur une fiche imprimable) restait valable, avec deux défauts : la fiche ne s'affichait qu'une fois, et le code de classe affiché ne servait à rien. Adam n'ayant pas répondu au choix proposé, j'ai construit les deux options sans dépendance extérieure :
|
||||||
|
|
||||||
|
- **Rejoindre avec le code de classe** (façon n°1 du CLAUDE.md) : `/rejoindre` (code + prénom + nom facultatif, limité à 10 envois par minute) → demande visible sur la page de la classe (rafraîchie toute seule quand une demande arrive) → le prof accepte → le navigateur de l'élève affiche **une seule fois** son identifiant et son code, et le connecte. Le code en clair n'existe que dans la demande (chiffré par `APP_KEY`), supprimée à l'affichage ; une demande non traitée est supprimée au bout d'une heure (`model:prune`, tous les quarts d'heure). Seul le navigateur qui a envoyé la demande peut la suivre (jeton en session, empreinte SHA-256 en base). Classe archivée ou code faux : refus. Le prof projette le code ou un QR qui pré-remplit le code.
|
||||||
|
- **Fiches en lot** : cases à cocher dans la liste des élèves, « Nouvelles fiches pour la sélection » (nouveaux codes et QR, anciens codes invalidés).
|
||||||
|
- Lien « Rejoins ta classe avec son code » sur la page de connexion ; politique de confidentialité mise à jour (nouvelle donnée : la demande, au plus une heure).
|
||||||
|
- 6 tests Pest (41 au total) ; vérifié en production : `/rejoindre` 200 avec CSP stricte, code faux refusé avec message, table créée, nettoyage planifié.
|
||||||
|
|
||||||
|
**Clé des sauvegardes** : je ne peux ni ne dois voir la copie d'Adam. Procédure de preuve ajoutée au runbook des sauvegardes : un message chiffré sur le serveur avec la clé publique, que la copie doit déchiffrer sur le Mac. Fichier de test déposé sur le Bureau d'Adam. Corrigé aussi la commande de copie du runbook (un `scp` d'un fichier de root échouait).
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ révèle rien.
|
||||||
|
|
||||||
## Où est la clé de déchiffrement
|
## Où est la clé de déchiffrement
|
||||||
|
|
||||||
- Clé privée age : `/root/.age/cle-sauvegardes.txt`, permissions `600`, **ne quitte jamais le serveur**.
|
- Clé privée age : `/root/.age/cle-sauvegardes.txt`, permissions `600`. Elle ne quitte le serveur que pour la copie de secours d'Adam (ci-dessous), jamais vers Git ni ailleurs.
|
||||||
- Clé publique (sans risque à diffuser) : voir la sortie de `age-keygen` lors du déploiement, ou :
|
- Clé publique (sans risque à diffuser) : voir la sortie de `age-keygen` lors du déploiement, ou :
|
||||||
```
|
```
|
||||||
sudo grep 'public key' /root/.age/cle-sauvegardes.txt
|
sudo grep 'public key' /root/.age/cle-sauvegardes.txt
|
||||||
|
|
@ -34,9 +34,23 @@ façon de déchiffrer les sauvegardes existantes. Exemple pour la récupérer (
|
||||||
lancer depuis le Mac, le fichier arrive en clair sur le Mac donc à stocker
|
lancer depuis le Mac, le fichier arrive en clair sur le Mac donc à stocker
|
||||||
immédiatement dans un endroit sûr puis à effacer du téléchargement) :
|
immédiatement dans un endroit sûr puis à effacer du téléchargement) :
|
||||||
```
|
```
|
||||||
scp paillasse:/root/.age/cle-sauvegardes.txt ~/Desktop/
|
# La clé appartient à root : un simple scp échouerait. La redirection écrit
|
||||||
# puis la mettre immédiatement dans un gestionnaire de mots de passe,
|
# le fichier sur le Mac sans jamais l'afficher.
|
||||||
# et supprimer la copie en clair sur le Desktop.
|
ssh paillasse 'sudo cat /root/.age/cle-sauvegardes.txt' > ~/Desktop/cle-sauvegardes-syllabus.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis la ranger dans le gestionnaire de mots de passe (pièce jointe d'une
|
||||||
|
fiche « Syllabus — clé des sauvegardes ») ou sur une clé USB chiffrée, et
|
||||||
|
**supprimer la copie du Bureau**.
|
||||||
|
|
||||||
|
### Prouver que la copie fonctionne (sans montrer la clé)
|
||||||
|
|
||||||
|
Sur le serveur, chiffrer un message de test avec la clé **publique** ; sur le
|
||||||
|
Mac, le déchiffrer avec la **copie** (installer `age` : `brew install age`) :
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh paillasse 'sudo bash -c "PUB=\$(grep -oP \"(?<=public key: )age1\\S+\" /root/.age/cle-sauvegardes.txt); echo \"copie OK \$(date +%F)\" | age -r \$PUB"' > ~/Desktop/test-cle.age
|
||||||
|
age -d -i <chemin de la copie> ~/Desktop/test-cle.age # doit afficher « copie OK … »
|
||||||
```
|
```
|
||||||
|
|
||||||
## Identifiants Dedibackup
|
## Identifiants Dedibackup
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Classe;
|
use App\Models\Classe;
|
||||||
|
use App\Models\DemandeInscription;
|
||||||
use App\Models\Eleve;
|
use App\Models\Eleve;
|
||||||
use App\Models\Seance;
|
use App\Models\Seance;
|
||||||
use App\Services\GenerateurCompteEleve;
|
use App\Services\GenerateurCompteEleve;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
@ -30,6 +32,7 @@ class ClasseController extends Controller
|
||||||
|
|
||||||
return view('mes-classes.show', [
|
return view('mes-classes.show', [
|
||||||
'classe' => $classe->load('eleves'),
|
'classe' => $classe->load('eleves'),
|
||||||
|
'demandes' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->oldest()->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,25 +107,64 @@ class ClasseController extends Controller
|
||||||
*/
|
*/
|
||||||
private function creerEleve(Classe $classe, string $prenom, ?string $nom): array
|
private function creerEleve(Classe $classe, string $prenom, ?string $nom): array
|
||||||
{
|
{
|
||||||
$identifiant = $this->generateur->genererIdentifiant($prenom, $nom);
|
$fiche = $this->generateur->creerDans($classe, $prenom, $nom);
|
||||||
|
unset($fiche['eleve']);
|
||||||
|
|
||||||
|
return $fiche;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accepterDemande(Classe $classe, DemandeInscription $demande): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->verifierProprietaire($classe);
|
||||||
|
abort_if($demande->classe_id !== $classe->id, 404);
|
||||||
|
|
||||||
|
if ($demande->statut === DemandeInscription::EN_ATTENTE && ! $classe->estArchivee()) {
|
||||||
|
$fiche = $this->generateur->creerDans($classe, $demande->prenom, $demande->nom);
|
||||||
|
$demande->forceFill([
|
||||||
|
'statut' => DemandeInscription::ACCEPTEE,
|
||||||
|
'eleve_id' => $fiche['eleve']->id,
|
||||||
|
'code_secret_chiffre' => $fiche['code_secret'],
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('mes-classes.show', $classe);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refuserDemande(Classe $classe, DemandeInscription $demande): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->verifierProprietaire($classe);
|
||||||
|
abort_if($demande->classe_id !== $classe->id, 404);
|
||||||
|
|
||||||
|
$demande->forceFill(['statut' => DemandeInscription::REFUSEE])->save();
|
||||||
|
|
||||||
|
return redirect()->route('mes-classes.show', $classe);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nombreDemandes(Classe $classe): JsonResponse
|
||||||
|
{
|
||||||
|
$this->verifierProprietaire($classe);
|
||||||
|
|
||||||
|
return response()->json(['en_attente' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->count()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nouvelles fiches (nouveau code, nouveau QR) pour les élèves cochés :
|
||||||
|
* la fiche n'est visible qu'une fois, ceci permet de la refaire en lot.
|
||||||
|
*/
|
||||||
|
public function nouvellesFiches(Request $request, Classe $classe): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->verifierProprietaire($classe);
|
||||||
|
$ids = $request->validate(['eleves' => ['required', 'array'], 'eleves.*' => ['integer']])['eleves'];
|
||||||
|
|
||||||
|
$fiches = [];
|
||||||
|
foreach ($classe->eleves()->whereIn('id', $ids)->orderBy('prenom')->get() as $eleve) {
|
||||||
$codeSecret = $this->generateur->genererCodeSecret();
|
$codeSecret = $this->generateur->genererCodeSecret();
|
||||||
$jetonQr = Str::random(32);
|
$jetonQr = Str::random(32);
|
||||||
|
$eleve->forceFill(['code_secret' => Hash::make($codeSecret), 'jeton_qr' => $jetonQr])->save();
|
||||||
|
$fiches[] = ['identifiant' => $eleve->identifiant, 'prenom' => $eleve->prenom, 'nom' => $eleve->nom, 'code_secret' => $codeSecret, 'jeton_qr' => $jetonQr];
|
||||||
|
}
|
||||||
|
|
||||||
$classe->eleves()->create([
|
return redirect()->route('mes-classes.show', $classe)->with('fiches', $fiches);
|
||||||
'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,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
115
portail/app/Http/Controllers/RejoindreClasseController.php
Normal file
115
portail/app/Http/Controllers/RejoindreClasseController.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Classe;
|
||||||
|
use App\Models\DemandeInscription;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un élève rejoint une classe avec son code (façon n°1 du CLAUDE.md) :
|
||||||
|
* il donne son prénom, le prof accepte, l'élève voit une fois son
|
||||||
|
* identifiant et son code secret, et il est connecté. Aucun e-mail.
|
||||||
|
*/
|
||||||
|
class RejoindreClasseController extends Controller
|
||||||
|
{
|
||||||
|
private const CLE_SESSION = 'demande_inscription_jeton';
|
||||||
|
|
||||||
|
public function formulaire(Request $request): View
|
||||||
|
{
|
||||||
|
return view('rejoindre.formulaire', ['code' => Str::upper((string) $request->query('code', ''))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function demander(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$donnees = $request->validate([
|
||||||
|
'code' => ['required', 'string', 'size:6'],
|
||||||
|
'prenom' => ['required', 'string', 'max:100'],
|
||||||
|
'nom' => ['nullable', 'string', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$classe = Classe::where('code_classe', Str::upper($donnees['code']))->first();
|
||||||
|
|
||||||
|
if ($classe === null || $classe->estArchivee()) {
|
||||||
|
return back()->withInput()->withErrors(['code' => 'Ce code ne correspond à aucune classe ouverte. Vérifie-le auprès de ton prof.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jeton = Str::random(40);
|
||||||
|
DemandeInscription::create([
|
||||||
|
'classe_id' => $classe->id,
|
||||||
|
'prenom' => trim($donnees['prenom']),
|
||||||
|
'nom' => isset($donnees['nom']) ? trim($donnees['nom']) : null,
|
||||||
|
'jeton_empreinte' => DemandeInscription::empreinte($jeton),
|
||||||
|
'statut' => DemandeInscription::EN_ATTENTE,
|
||||||
|
]);
|
||||||
|
$request->session()->put(self::CLE_SESSION, $jeton);
|
||||||
|
|
||||||
|
return redirect()->route('rejoindre.attente');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attente(Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$demande = $this->demandeDeCeNavigateur($request);
|
||||||
|
|
||||||
|
if ($demande === null) {
|
||||||
|
return redirect()->route('rejoindre');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($demande->statut === DemandeInscription::ACCEPTEE) {
|
||||||
|
return redirect()->route('rejoindre.bienvenue');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($demande->statut === DemandeInscription::REFUSEE) {
|
||||||
|
$demande->delete();
|
||||||
|
$request->session()->forget(self::CLE_SESSION);
|
||||||
|
|
||||||
|
return view('rejoindre.refusee');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('rejoindre.attente', ['demande' => $demande->load('classe')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function etat(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(['statut' => $this->demandeDeCeNavigateur($request)?->statut]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le code secret n'est montré qu'une fois : la demande (et le code en
|
||||||
|
* clair qu'elle porte) est supprimée dès l'affichage, l'élève est connecté.
|
||||||
|
*/
|
||||||
|
public function bienvenue(Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$demande = $this->demandeDeCeNavigateur($request);
|
||||||
|
|
||||||
|
if ($demande === null || $demande->statut !== DemandeInscription::ACCEPTEE || $demande->eleve === null) {
|
||||||
|
return redirect()->route(Auth::guard('eleve')->check() ? 'eleve' : 'rejoindre');
|
||||||
|
}
|
||||||
|
|
||||||
|
$eleve = $demande->eleve;
|
||||||
|
$codeSecret = $demande->code_secret_chiffre;
|
||||||
|
$demande->delete();
|
||||||
|
$request->session()->forget(self::CLE_SESSION);
|
||||||
|
|
||||||
|
Auth::guard('eleve')->login($eleve);
|
||||||
|
$request->session()->regenerate();
|
||||||
|
$eleve->forceFill(['jeton_qr' => null, 'derniere_connexion_a' => now()])->save();
|
||||||
|
$request->session()->put('jeton_session_eleve', $eleve->demarreNouvelleSession());
|
||||||
|
|
||||||
|
return view('rejoindre.bienvenue', ['eleve' => $eleve, 'codeSecret' => $codeSecret]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function demandeDeCeNavigateur(Request $request): ?DemandeInscription
|
||||||
|
{
|
||||||
|
$jeton = $request->session()->get(self::CLE_SESSION);
|
||||||
|
|
||||||
|
return is_string($jeton)
|
||||||
|
? DemandeInscription::where('jeton_empreinte', DemandeInscription::empreinte($jeton))->first()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -82,7 +82,7 @@ class PolitiqueSecuriteContenu
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($route->getName(), ['login', 'register'], true)) {
|
if (in_array($route->getName(), ['login', 'register'], true) || str_starts_with((string) $route->getName(), 'rejoindre')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,11 @@ class Classe extends Model
|
||||||
return $this->hasMany(Eleve::class);
|
return $this->hasMany(Eleve::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function demandesInscription(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DemandeInscription::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function seances(): HasMany
|
public function seances(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Seance::class);
|
return $this->hasMany(Seance::class);
|
||||||
|
|
|
||||||
52
portail/app/Models/DemandeInscription.php
Normal file
52
portail/app/Models/DemandeInscription.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Prunable;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un élève demande à rejoindre une classe avec son code (façon n°1 du
|
||||||
|
* CLAUDE.md) ; le prof accepte ou refuse. Données minimales : prénom, nom
|
||||||
|
* facultatif. Supprimée dès que l'élève a vu son code, ou au bout d'une heure.
|
||||||
|
*/
|
||||||
|
class DemandeInscription extends Model
|
||||||
|
{
|
||||||
|
use Prunable;
|
||||||
|
|
||||||
|
public const EN_ATTENTE = 'en_attente';
|
||||||
|
|
||||||
|
public const ACCEPTEE = 'acceptee';
|
||||||
|
|
||||||
|
public const REFUSEE = 'refusee';
|
||||||
|
|
||||||
|
protected $table = 'demandes_inscription';
|
||||||
|
|
||||||
|
protected $fillable = ['classe_id', 'prenom', 'nom', 'jeton_empreinte', 'statut'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'code_secret_chiffre' => 'encrypted',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function classe(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Classe::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function eleve(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Eleve::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function empreinte(string $jeton): string
|
||||||
|
{
|
||||||
|
return hash('sha256', $jeton);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prunable(): Builder
|
||||||
|
{
|
||||||
|
return static::where('created_at', '<', now()->subHour());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Classe;
|
||||||
use App\Models\Eleve;
|
use App\Models\Eleve;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,4 +42,27 @@ class GenerateurCompteEleve
|
||||||
|
|
||||||
return $code;
|
return $code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée le compte d'un élève et renvoie sa fiche (le code secret en clair
|
||||||
|
* n'existe que dans cette fiche : il est stocké haché).
|
||||||
|
*
|
||||||
|
* @return array{eleve: Eleve, identifiant: string, prenom: string, nom: ?string, code_secret: string, jeton_qr: string}
|
||||||
|
*/
|
||||||
|
public function creerDans(Classe $classe, string $prenom, ?string $nom): array
|
||||||
|
{
|
||||||
|
$identifiant = $this->genererIdentifiant($prenom, $nom);
|
||||||
|
$codeSecret = $this->genererCodeSecret();
|
||||||
|
$jetonQr = Str::random(32);
|
||||||
|
|
||||||
|
$eleve = $classe->eleves()->create([
|
||||||
|
'prenom' => $prenom,
|
||||||
|
'nom' => $nom,
|
||||||
|
'identifiant' => $identifiant,
|
||||||
|
'code_secret' => Hash::make($codeSecret),
|
||||||
|
'jeton_qr' => $jetonQr,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['eleve' => $eleve, 'identifiant' => $identifiant, 'prenom' => $prenom, 'nom' => $nom, 'code_secret' => $codeSecret, 'jeton_qr' => $jetonQr];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('demandes_inscription', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('classe_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('prenom', 100);
|
||||||
|
$table->string('nom', 100)->nullable();
|
||||||
|
// Empreinte du jeton gardé dans la session du navigateur de l'élève :
|
||||||
|
// seul ce navigateur peut suivre la demande et recevoir son code.
|
||||||
|
$table->char('jeton_empreinte', 64)->unique();
|
||||||
|
$table->string('statut', 20)->default('en_attente');
|
||||||
|
$table->foreignId('eleve_id')->nullable()->constrained('eleves')->cascadeOnDelete();
|
||||||
|
// Code secret en clair (chiffré par APP_KEY), le temps que l'élève le
|
||||||
|
// voie une fois ; la demande est supprimée juste après.
|
||||||
|
$table->text('code_secret_chiffre')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('demandes_inscription');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<label class="grid gap-2 font-extrabold">Identifiant<input name="identifiant" value="{{ old('identifiant') }}" autocomplete="username" class="w-full rounded-xl border border-[#c4c7c5] bg-white px-4 py-3 text-base font-semibold text-black placeholder:text-[#5f6368] focus:border-[#0b57d0] focus:outline-none focus:ring-2 focus:ring-[#0b57d0]/30 font-mono" placeholder="lea.m.2nde-b"></label>
|
<label class="grid gap-2 font-extrabold">Identifiant<input name="identifiant" value="{{ old('identifiant') }}" autocomplete="username" class="w-full rounded-xl border border-[#c4c7c5] bg-white px-4 py-3 text-base font-semibold text-black placeholder:text-[#5f6368] focus:border-[#0b57d0] focus:outline-none focus:ring-2 focus:ring-[#0b57d0]/30 font-mono" placeholder="lea.m.2nde-b"></label>
|
||||||
<label class="grid gap-2 font-extrabold">Code secret<input type="password" name="code" autocomplete="current-password" class="w-full rounded-xl border border-[#c4c7c5] bg-white px-4 py-3 text-base font-semibold text-black placeholder:text-[#5f6368] focus:border-[#0b57d0] focus:outline-none focus:ring-2 focus:ring-[#0b57d0]/30 font-mono"></label>
|
<label class="grid gap-2 font-extrabold">Code secret<input type="password" name="code" autocomplete="current-password" class="w-full rounded-xl border border-[#c4c7c5] bg-white px-4 py-3 text-base font-semibold text-black placeholder:text-[#5f6368] focus:border-[#0b57d0] focus:outline-none focus:ring-2 focus:ring-[#0b57d0]/30 font-mono"></label>
|
||||||
<p class="font-medium">Ils sont sur ta fiche de connexion. Tu peux aussi scanner le QR.</p>
|
<p class="font-medium">Ils sont sur ta fiche de connexion. Tu peux aussi scanner le QR.</p>
|
||||||
|
<p class="font-medium">Pas encore d'identifiant ? <a href="{{ route('rejoindre') }}" class="font-bold underline underline-offset-4">Rejoins ta classe avec son code</a>.</p>
|
||||||
<div><button type="submit" style="display:inline-flex;align-items:center;gap:10px;height:52px;padding:0 28px;border:0;border-radius:999px;background:#0b57d0;color:#fff;font-size:16px;font-weight:700;cursor:pointer">Entrer <span class="font-mono" aria-hidden="true">→</span></button></div>
|
<div><button type="submit" style="display:inline-flex;align-items:center;gap:10px;height:52px;padding:0 28px;border:0;border-radius:999px;background:#0b57d0;color:#fff;font-size:16px;font-weight:700;cursor:pointer">Entrer <span class="font-mono" aria-hidden="true">→</span></button></div>
|
||||||
</form>
|
</form>
|
||||||
<form action="{{ route('connexion.prof') }}" method="post" class="grid content-start gap-5 p-8" style="border:1px solid #e3e5e8;border-radius:24px;background:#fff">
|
<form action="{{ route('connexion.prof') }}" method="post" class="grid content-start gap-5 p-8" style="border:1px solid #e3e5e8;border-radius:24px;background:#fff">
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
<li><strong>aucune adresse e-mail</strong> ;</li>
|
<li><strong>aucune adresse e-mail</strong> ;</li>
|
||||||
<li>sessions de lab : date, état, machines virtuelles attribuées ;</li>
|
<li>sessions de lab : date, état, machines virtuelles attribuées ;</li>
|
||||||
<li>résultats de la vérification automatique (réussi ou non, note, message par vérification).</li>
|
<li>résultats de la vérification automatique (réussi ou non, note, message par vérification).</li>
|
||||||
|
<li>s'il rejoint sa classe avec le code de classe : le prénom et le nom (facultatif) qu'il saisit, le temps que l'enseignant accepte ou refuse sa demande.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Ce que l’élève tape dans la console de sa machine virtuelle n’est pas enregistré par Syllabus. Son enseignant peut suivre la console en direct, en lecture seule, pendant la séance.</p>
|
<p>Ce que l’élève tape dans la console de sa machine virtuelle n’est pas enregistré par Syllabus. Son enseignant peut suivre la console en direct, en lecture seule, pendant la séance.</p>
|
||||||
<h3>Enseignants</h3>
|
<h3>Enseignants</h3>
|
||||||
|
|
@ -70,6 +71,7 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Machines virtuelles</strong> et leur contenu : supprimées à la fin de la session de lab (45 minutes par défaut).</li>
|
<li><strong>Machines virtuelles</strong> et leur contenu : supprimées à la fin de la session de lab (45 minutes par défaut).</li>
|
||||||
<li><strong>Classe et données de ses élèves</strong> : jusqu’à l’archivage de la classe par l’enseignant, puis <strong>supprimées automatiquement 90 jours après l’archivage</strong>.</li>
|
<li><strong>Classe et données de ses élèves</strong> : jusqu’à l’archivage de la classe par l’enseignant, puis <strong>supprimées automatiquement 90 jours après l’archivage</strong>.</li>
|
||||||
|
<li><strong>Demande pour rejoindre une classe</strong> : supprimée dès que l’élève a reçu son identifiant, et au plus tard une heure après son envoi.</li>
|
||||||
<li><strong>Compte enseignant</strong> : tant qu’il est utilisé ; supprimé sur demande.</li>
|
<li><strong>Compte enseignant</strong> : tant qu’il est utilisé ; supprimé sur demande.</li>
|
||||||
<li><strong>Journaux techniques</strong> : 14 jours.</li>
|
<li><strong>Journaux techniques</strong> : 14 jours.</li>
|
||||||
<li><strong>Sauvegardes</strong> : chiffrées, conservées au plus 5 semaines (7 sauvegardes quotidiennes et 4 hebdomadaires). Une donnée supprimée disparaît donc des sauvegardes au plus tard 5 semaines après sa suppression.</li>
|
<li><strong>Sauvegardes</strong> : chiffrées, conservées au plus 5 semaines (7 sauvegardes quotidiennes et 4 hebdomadaires). Une donnée supprimée disparaît donc des sauvegardes au plus tard 5 semaines après sa suppression.</li>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,33 @@
|
||||||
<p class="mt-4 border border-[#e3e5e8] rounded-2xl bg-[#e6f4ea] p-4 font-semibold">Classe créée. Ajoute maintenant tes élèves ci-dessous.</p>
|
<p class="mt-4 border border-[#e3e5e8] rounded-2xl bg-[#e6f4ea] p-4 font-semibold">Classe créée. Ajoute maintenant tes élèves ci-dessous.</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@unless ($classe->estArchivee())
|
||||||
|
<section class="mt-10 grid gap-6 rounded-2xl border border-[#e3e5e8] bg-white p-6 md:grid-cols-[1fr_auto]" aria-labelledby="t-rejoindre">
|
||||||
|
<div>
|
||||||
|
<h2 id="t-rejoindre" class="text-2xl font-extrabold">Faire rejoindre tes élèves</h2>
|
||||||
|
<p class="mt-2 font-semibold">Projette ce code ou ce QR. Chaque élève va sur <span class="font-mono">{{ preg_replace('#^https?://#', '', route('rejoindre')) }}</span>, tape le code et son prénom ; tu acceptes sa demande ici, et il reçoit à l'écran son identifiant et son code secret. Aucun e-mail.</p>
|
||||||
|
<p class="mt-4 font-mono text-5xl font-bold tracking-[0.3em]">{{ $classe->code_classe }}</p>
|
||||||
|
<h3 class="mt-6 text-lg font-extrabold">Demandes en attente ({{ $demandes->count() }})</h3>
|
||||||
|
@if ($demandes->isEmpty())
|
||||||
|
<p class="mt-2 font-semibold text-[#5f6368]">Aucune pour l'instant. Cette liste se met à jour toute seule.</p>
|
||||||
|
@else
|
||||||
|
<ul class="mt-2 grid gap-2">
|
||||||
|
@foreach ($demandes as $demande)
|
||||||
|
<li class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#e3e5e8] p-3">
|
||||||
|
<span class="font-extrabold">{{ $demande->prenom }}@if ($demande->nom) {{ $demande->nom }}@endif <span class="font-mono text-sm font-semibold text-[#5f6368]">· {{ $demande->created_at->diffForHumans() }}</span></span>
|
||||||
|
<span class="flex gap-2">
|
||||||
|
<form action="{{ route('mes-classes.demandes.accepter', [$classe, $demande]) }}" method="post">@csrf<button type="submit" class="inline-flex h-10 items-center rounded-full bg-[#0b57d0] px-4 font-bold text-white">Accepter</button></form>
|
||||||
|
<form action="{{ route('mes-classes.demandes.refuser', [$classe, $demande]) }}" method="post">@csrf<button type="submit" class="inline-flex h-10 items-center rounded-full border border-[#dadce0] px-4 font-bold">Refuser</button></form>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="justify-self-center">{!! \App\Support\QrCode::svg(route('rejoindre', ['code' => $classe->code_classe])) !!}</div>
|
||||||
|
</section>
|
||||||
|
@endunless
|
||||||
|
|
||||||
@if (session('fiches'))
|
@if (session('fiches'))
|
||||||
<section class="mt-10 border border-[#e3e5e8] rounded-2xl bg-[#fef7e0] p-6 print:border-0 print:bg-white" aria-labelledby="t-fiches">
|
<section class="mt-10 border border-[#e3e5e8] rounded-2xl bg-[#fef7e0] p-6 print:border-0 print:bg-white" aria-labelledby="t-fiches">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 print:hidden">
|
<div class="flex flex-wrap items-center justify-between gap-3 print:hidden">
|
||||||
|
|
@ -69,16 +96,26 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="mt-10 border border-[#e3e5e8] rounded-2xl bg-white" aria-labelledby="t-eleves">
|
<section class="mt-10 border border-[#e3e5e8] rounded-2xl bg-white" aria-labelledby="t-eleves">
|
||||||
<h2 id="t-eleves" class="p-6 pb-0 text-2xl font-extrabold">Élèves ({{ $classe->eleves->count() }})</h2>
|
<div class="flex flex-wrap items-center justify-between gap-3 p-6 pb-0">
|
||||||
|
<h2 id="t-eleves" class="text-2xl font-extrabold">Élèves ({{ $classe->eleves->count() }})</h2>
|
||||||
|
@if ($classe->eleves->isNotEmpty())
|
||||||
|
<form id="form-fiches" action="{{ route('mes-classes.fiches', $classe) }}" method="post" data-confirmer="Générer de nouvelles fiches pour les élèves cochés ? Leurs anciens codes ne fonctionneront plus.">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="inline-flex h-10 items-center rounded-full border border-[#0b57d0] px-4 font-bold text-[#0b57d0]">Nouvelles fiches pour la sélection</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@error('eleves')<p class="px-6 pt-2 font-semibold text-[#A63D2A]">Coche au moins un élève.</p>@enderror
|
||||||
@if ($classe->eleves->isEmpty())
|
@if ($classe->eleves->isEmpty())
|
||||||
<p class="p-6 font-semibold">Aucun élève pour l'instant.</p>
|
<p class="p-6 font-semibold">Aucun élève pour l'instant.</p>
|
||||||
@else
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="mt-4 w-full min-w-[480px] text-left">
|
<table class="mt-4 w-full min-w-[480px] text-left">
|
||||||
<thead class="bg-[#0b57d0] text-white"><tr><th class="px-4 py-2">Élève</th><th class="px-4 py-2 font-mono text-xs">Identifiant</th><th class="px-4 py-2">Dernière connexion</th><th class="px-4 py-2"></th></tr></thead>
|
<thead class="bg-[#0b57d0] text-white"><tr><th class="px-4 py-2"><input type="checkbox" data-tout-cocher aria-label="Tout sélectionner"></th><th class="px-4 py-2">Élève</th><th class="px-4 py-2 font-mono text-xs">Identifiant</th><th class="px-4 py-2">Dernière connexion</th><th class="px-4 py-2"></th></tr></thead>
|
||||||
<tbody class="divide-y divide-[#e3e5e8]">
|
<tbody class="divide-y divide-[#e3e5e8]">
|
||||||
@foreach ($classe->eleves as $eleve)
|
@foreach ($classe->eleves as $eleve)
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="px-4 py-3"><input type="checkbox" name="eleves[]" value="{{ $eleve->id }}" form="form-fiches" aria-label="Sélectionner {{ $eleve->prenom }}"></td>
|
||||||
<td class="px-4 py-3 font-extrabold">{{ $eleve->nomAffiche() }}</td>
|
<td class="px-4 py-3 font-extrabold">{{ $eleve->nomAffiche() }}</td>
|
||||||
<td class="px-4 py-3 font-mono text-sm">{{ $eleve->identifiant }}</td>
|
<td class="px-4 py-3 font-mono text-sm">{{ $eleve->identifiant }}</td>
|
||||||
<td class="px-4 py-3 font-semibold">{{ $eleve->derniere_connexion_a?->diffForHumans() ?? 'jamais' }}</td>
|
<td class="px-4 py-3 font-semibold">{{ $eleve->derniere_connexion_a?->diffForHumans() ?? 'jamais' }}</td>
|
||||||
|
|
@ -165,6 +202,22 @@
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<script nonce="{{ Vite::cspNonce() }}">
|
<script nonce="{{ Vite::cspNonce() }}">
|
||||||
|
document.querySelectorAll('[data-tout-cocher]').forEach(function (c) {
|
||||||
|
c.addEventListener('change', function () { document.querySelectorAll('input[name="eleves[]"]').forEach(function (x) { x.checked = c.checked; }); });
|
||||||
|
});
|
||||||
|
@unless ($classe->estArchivee())
|
||||||
|
(function () {
|
||||||
|
var vues = {{ $demandes->count() }};
|
||||||
|
setInterval(function () {
|
||||||
|
fetch('{{ route('mes-classes.demandes.nombre', $classe) }}', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
var occupe = document.activeElement && /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName);
|
||||||
|
if (d.en_attente !== vues && !occupe) location.reload();
|
||||||
|
}).catch(function () {});
|
||||||
|
}, 4000);
|
||||||
|
})();
|
||||||
|
@endunless
|
||||||
document.querySelectorAll('[data-imprimer]').forEach(function (b) { b.addEventListener('click', function () { window.print(); }); });
|
document.querySelectorAll('[data-imprimer]').forEach(function (b) { b.addEventListener('click', function () { window.print(); }); });
|
||||||
document.querySelectorAll('form[data-confirmer]').forEach(function (f) {
|
document.querySelectorAll('form[data-confirmer]').forEach(function (f) {
|
||||||
f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); });
|
f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); });
|
||||||
|
|
|
||||||
21
portail/resources/views/rejoindre/attente.blade.php
Normal file
21
portail/resources/views/rejoindre/attente.blade.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
@extends('layouts.vitrine')
|
||||||
|
|
||||||
|
@section('titre', 'Demande envoyée')
|
||||||
|
|
||||||
|
@section('contenu')
|
||||||
|
<div class="mx-auto max-w-xl px-5 py-20 text-center lg:px-10">
|
||||||
|
<p class="font-mono text-sm font-semibold">{{ $demande->classe->nom }}</p>
|
||||||
|
<h1 class="mt-2 text-4xl font-extrabold tracking-tight">Demande envoyée, {{ $demande->prenom }} !</h1>
|
||||||
|
<p class="mt-4 text-lg font-semibold">Attends que ton prof l'accepte. Garde cette page ouverte : ton identifiant et ton code s'afficheront ici.</p>
|
||||||
|
</div>
|
||||||
|
<script nonce="{{ Vite::cspNonce() }}">
|
||||||
|
(function () {
|
||||||
|
setInterval(function () {
|
||||||
|
fetch('{{ route('rejoindre.etat') }}', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) { if (d.statut !== 'en_attente') location.reload(); })
|
||||||
|
.catch(function () {});
|
||||||
|
}, 2000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
16
portail/resources/views/rejoindre/bienvenue.blade.php
Normal file
16
portail/resources/views/rejoindre/bienvenue.blade.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
@extends('layouts.vitrine')
|
||||||
|
|
||||||
|
@section('titre', 'Bienvenue')
|
||||||
|
|
||||||
|
@section('contenu')
|
||||||
|
<div class="mx-auto max-w-xl px-5 py-14 lg:px-10">
|
||||||
|
<h1 class="text-4xl font-extrabold tracking-tight">Bienvenue, {{ $eleve->prenom }} !</h1>
|
||||||
|
<p class="mt-3 text-lg font-semibold">Note ou photographie ces deux informations <strong>maintenant</strong> : elles ne s'afficheront plus jamais. Tu en auras besoin pour te reconnecter.</p>
|
||||||
|
<div class="mt-8 grid gap-4 rounded-2xl border-2 border-[#13233A] bg-[#fef7e0] p-6">
|
||||||
|
<p class="font-mono text-sm font-semibold">Identifiant<br><span class="text-3xl font-bold">{{ $eleve->identifiant }}</span></p>
|
||||||
|
<p class="font-mono text-sm font-semibold">Code secret<br><span class="text-3xl font-bold tracking-[0.2em]">{{ $codeSecret }}</span></p>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 font-semibold">Si tu le perds, ton prof peut te donner un nouveau code.</p>
|
||||||
|
<a href="{{ route('eleve') }}" class="mt-8 inline-flex h-12 items-center justify-center rounded-full bg-[#0b57d0] px-6 text-lg font-bold text-white">C'est noté, aller à mon espace</a>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
28
portail/resources/views/rejoindre/formulaire.blade.php
Normal file
28
portail/resources/views/rejoindre/formulaire.blade.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
@extends('layouts.vitrine')
|
||||||
|
|
||||||
|
@section('titre', 'Rejoindre ma classe')
|
||||||
|
|
||||||
|
@section('contenu')
|
||||||
|
<div class="mx-auto max-w-xl px-5 py-14 lg:px-10">
|
||||||
|
<h1 class="text-4xl font-extrabold tracking-tight">Rejoindre ma classe</h1>
|
||||||
|
<p class="mt-3 text-lg font-semibold">Ton prof t'a donné un code de classe à 6 caractères. Ton prof va accepter ta demande, puis tu recevras ton identifiant et ton code secret sur cet écran.</p>
|
||||||
|
<form action="{{ route('rejoindre.demander') }}" method="post" class="mt-8 grid gap-4 rounded-2xl border border-[#e3e5e8] bg-white p-6">
|
||||||
|
@csrf
|
||||||
|
<label class="grid gap-1 font-bold">Code de la classe
|
||||||
|
<input name="code" value="{{ old('code', $code) }}" required maxlength="6" autocomplete="off" autocapitalize="characters" spellcheck="false"
|
||||||
|
class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-mono text-2xl font-bold uppercase tracking-[0.3em]">
|
||||||
|
</label>
|
||||||
|
@error('code')<p class="font-semibold text-[#A63D2A]">{{ $message }}</p>@enderror
|
||||||
|
<label class="grid gap-1 font-bold">Ton prénom
|
||||||
|
<input name="prenom" value="{{ old('prenom') }}" required maxlength="100" autocomplete="given-name" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">
|
||||||
|
</label>
|
||||||
|
@error('prenom')<p class="font-semibold text-[#A63D2A]">{{ $message }}</p>@enderror
|
||||||
|
<label class="grid gap-1 font-bold">Ton nom <span class="font-medium">(facultatif, visible de ton prof seulement)</span>
|
||||||
|
<input name="nom" value="{{ old('nom') }}" maxlength="100" autocomplete="family-name" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">
|
||||||
|
</label>
|
||||||
|
<p class="text-sm font-semibold text-[#5f6368]">Aucune adresse e-mail n'est demandée.</p>
|
||||||
|
<button type="submit" class="inline-flex h-12 items-center justify-center rounded-full bg-[#0b57d0] px-6 text-lg font-bold text-white">Envoyer ma demande</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-6 font-semibold">Tu as déjà un identifiant ? <a href="{{ route('login') }}" class="underline underline-offset-4">Connecte-toi</a>.</p>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
10
portail/resources/views/rejoindre/refusee.blade.php
Normal file
10
portail/resources/views/rejoindre/refusee.blade.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
@extends('layouts.vitrine')
|
||||||
|
|
||||||
|
@section('titre', 'Demande refusée')
|
||||||
|
|
||||||
|
@section('contenu')
|
||||||
|
<div class="mx-auto max-w-xl px-5 py-20 text-center lg:px-10">
|
||||||
|
<h1 class="text-4xl font-extrabold tracking-tight">Ton prof n'a pas accepté la demande</h1>
|
||||||
|
<p class="mt-4 text-lg font-semibold">Vérifie le code de la classe et ton prénom avec lui, puis <a href="{{ route('rejoindre') }}" class="underline underline-offset-4">réessaie</a>.</p>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\DemandeInscription;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
@ -20,3 +21,6 @@ Schedule::command('horizon:snapshot')->everyFiveMinutes();
|
||||||
// Étape 14 (RGPD) : classes archivées depuis 90 jours supprimées avec les
|
// Étape 14 (RGPD) : classes archivées depuis 90 jours supprimées avec les
|
||||||
// données de leurs élèves.
|
// données de leurs élèves.
|
||||||
Schedule::command('classes:purger-archivees')->dailyAt('02:30');
|
Schedule::command('classes:purger-archivees')->dailyAt('02:30');
|
||||||
|
|
||||||
|
// Demandes pour rejoindre une classe non traitées depuis une heure : supprimées.
|
||||||
|
Schedule::command('model:prune', ['--model' => [DemandeInscription::class]])->everyFifteenMinutes();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Http\Controllers\CorrectionController;
|
||||||
use App\Http\Controllers\EleveTableauDeBordController;
|
use App\Http\Controllers\EleveTableauDeBordController;
|
||||||
use App\Http\Controllers\InscriptionController;
|
use App\Http\Controllers\InscriptionController;
|
||||||
use App\Http\Controllers\RapportCspController;
|
use App\Http\Controllers\RapportCspController;
|
||||||
|
use App\Http\Controllers\RejoindreClasseController;
|
||||||
use App\Http\Controllers\SeanceController;
|
use App\Http\Controllers\SeanceController;
|
||||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
@ -71,6 +72,13 @@ Route::post('/connexion/prof', [ConnexionController::class, 'connexionProf'])->n
|
||||||
Route::post('/connexion/eleve', [ConnexionController::class, 'connexionEleve'])->name('connexion.eleve');
|
Route::post('/connexion/eleve', [ConnexionController::class, 'connexionEleve'])->name('connexion.eleve');
|
||||||
Route::get('/connexion/qr/{jeton}', [ConnexionController::class, 'connexionParQr'])->name('connexion.qr');
|
Route::get('/connexion/qr/{jeton}', [ConnexionController::class, 'connexionParQr'])->name('connexion.qr');
|
||||||
Route::post('/inscription', [InscriptionController::class, 'store'])->name('inscription.store');
|
Route::post('/inscription', [InscriptionController::class, 'store'])->name('inscription.store');
|
||||||
|
|
||||||
|
// Un élève rejoint une classe avec son code ; le prof accepte (aucun e-mail).
|
||||||
|
Route::get('/rejoindre', [RejoindreClasseController::class, 'formulaire'])->name('rejoindre');
|
||||||
|
Route::post('/rejoindre', [RejoindreClasseController::class, 'demander'])->middleware('throttle:10,1')->name('rejoindre.demander');
|
||||||
|
Route::get('/rejoindre/attente', [RejoindreClasseController::class, 'attente'])->name('rejoindre.attente');
|
||||||
|
Route::get('/rejoindre/etat', [RejoindreClasseController::class, 'etat'])->name('rejoindre.etat');
|
||||||
|
Route::get('/rejoindre/bienvenue', [RejoindreClasseController::class, 'bienvenue'])->name('rejoindre.bienvenue');
|
||||||
Route::post('/deconnexion', [ConnexionController::class, 'deconnexion'])->name('deconnexion');
|
Route::post('/deconnexion', [ConnexionController::class, 'deconnexion'])->name('deconnexion');
|
||||||
|
|
||||||
// --- Espace élève (protégé, session unique) ---
|
// --- Espace élève (protégé, session unique) ---
|
||||||
|
|
@ -92,6 +100,10 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
|
||||||
Route::post('/mes-classes/{classe}/eleves', [ClasseController::class, 'storeEleve'])->name('mes-classes.eleves.store');
|
Route::post('/mes-classes/{classe}/eleves', [ClasseController::class, 'storeEleve'])->name('mes-classes.eleves.store');
|
||||||
Route::post('/mes-classes/{classe}/eleves/import', [ClasseController::class, 'importerCsv'])->name('mes-classes.eleves.importer');
|
Route::post('/mes-classes/{classe}/eleves/import', [ClasseController::class, 'importerCsv'])->name('mes-classes.eleves.importer');
|
||||||
Route::post('/mes-classes/{classe}/eleves/{eleve}/regenerer-code', [ClasseController::class, 'regenererCode'])->name('mes-classes.eleves.regenererCode');
|
Route::post('/mes-classes/{classe}/eleves/{eleve}/regenerer-code', [ClasseController::class, 'regenererCode'])->name('mes-classes.eleves.regenererCode');
|
||||||
|
Route::post('/mes-classes/{classe}/demandes/{demande}/accepter', [ClasseController::class, 'accepterDemande'])->name('mes-classes.demandes.accepter');
|
||||||
|
Route::post('/mes-classes/{classe}/demandes/{demande}/refuser', [ClasseController::class, 'refuserDemande'])->name('mes-classes.demandes.refuser');
|
||||||
|
Route::get('/mes-classes/{classe}/demandes/nombre', [ClasseController::class, 'nombreDemandes'])->name('mes-classes.demandes.nombre');
|
||||||
|
Route::post('/mes-classes/{classe}/fiches', [ClasseController::class, 'nouvellesFiches'])->name('mes-classes.fiches');
|
||||||
Route::post('/mes-classes/{classe}/archiver', [ClasseController::class, 'archiver'])->name('mes-classes.archiver');
|
Route::post('/mes-classes/{classe}/archiver', [ClasseController::class, 'archiver'])->name('mes-classes.archiver');
|
||||||
Route::post('/mes-classes/{classe}/desarchiver', [ClasseController::class, 'desarchiver'])->name('mes-classes.desarchiver');
|
Route::post('/mes-classes/{classe}/desarchiver', [ClasseController::class, 'desarchiver'])->name('mes-classes.desarchiver');
|
||||||
Route::post('/mes-classes/{classe}/seances', [SeanceController::class, 'store'])->name('mes-classes.seances.store');
|
Route::post('/mes-classes/{classe}/seances', [SeanceController::class, 'store'])->name('mes-classes.seances.store');
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ test('chaque page publique s’affiche sans erreur', function () {
|
||||||
&& ! str_contains($r->uri(), '{')
|
&& ! str_contains($r->uri(), '{')
|
||||||
&& ! collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth')))
|
&& ! collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth')))
|
||||||
->reject(fn ($r) => str_starts_with($r->uri(), 'horizon') || str_starts_with($r->uri(), '_') || $r->uri() === 'up')
|
->reject(fn ($r) => str_starts_with($r->uri(), 'horizon') || str_starts_with($r->uri(), '_') || $r->uri() === 'up')
|
||||||
// Redirections voulues (ex. /statut vers le site de statut).
|
// Redirections voulues (ex. /statut vers le site de statut ; pages de
|
||||||
->reject(fn ($r) => $r->getActionName() === '\\Illuminate\\Routing\\RedirectController');
|
// suivi d'une demande pour rejoindre une classe, sans demande en cours).
|
||||||
|
->reject(fn ($r) => $r->getActionName() === '\\Illuminate\\Routing\\RedirectController'
|
||||||
|
|| in_array($r->getName(), ['rejoindre.attente', 'rejoindre.bienvenue'], true));
|
||||||
|
|
||||||
expect($pages)->not->toBeEmpty();
|
expect($pages)->not->toBeEmpty();
|
||||||
|
|
||||||
|
|
|
||||||
98
portail/tests/Feature/RejoindreClasseTest.php
Normal file
98
portail/tests/Feature/RejoindreClasseTest.php
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Classe;
|
||||||
|
use App\Models\DemandeInscription;
|
||||||
|
use App\Models\Eleve;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
function commeProf($test, User $prof)
|
||||||
|
{
|
||||||
|
return $test->withSession(['jeton_session_web' => $prof->demarreNouvelleSession()])->actingAs($prof, 'web');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('un élève rejoint la classe avec son code ; le prof accepte ; l’élève voit son code une seule fois et peut se reconnecter', function () {
|
||||||
|
$classe = Classe::factory()->create();
|
||||||
|
|
||||||
|
$this->post(route('rejoindre.demander'), ['code' => strtolower($classe->code_classe), 'prenom' => 'Inès', 'nom' => 'Martin'])
|
||||||
|
->assertRedirect(route('rejoindre.attente'));
|
||||||
|
$this->get(route('rejoindre.attente'))->assertOk()->assertSee('Demande envoyée, Inès');
|
||||||
|
$this->getJson(route('rejoindre.etat'))->assertJson(['statut' => 'en_attente']);
|
||||||
|
|
||||||
|
$demande = DemandeInscription::sole();
|
||||||
|
commeProf($this, $classe->prof)->get(route('mes-classes.show', $classe))->assertSee('Inès Martin')->assertSee($classe->code_classe);
|
||||||
|
commeProf($this, $classe->prof)->post(route('mes-classes.demandes.accepter', [$classe, $demande]))->assertRedirect();
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
$this->getJson(route('rejoindre.etat'))->assertJson(['statut' => 'acceptee']);
|
||||||
|
$page = $this->get(route('rejoindre.bienvenue'))->assertOk()->assertSee('ines.m');
|
||||||
|
preg_match('#Code secret<br><span[^>]*>([A-Z0-9]{6})</span>#', $page->getContent(), $m);
|
||||||
|
$code = $m[1];
|
||||||
|
|
||||||
|
expect(DemandeInscription::count())->toBe(0)
|
||||||
|
->and(Auth::guard('eleve')->user()?->identifiant)->toBe('ines.m');
|
||||||
|
|
||||||
|
// Une seule fois : un second affichage renvoie vers l'espace élève.
|
||||||
|
$this->get(route('rejoindre.bienvenue'))->assertRedirect(route('eleve'));
|
||||||
|
|
||||||
|
Auth::guard('eleve')->logout();
|
||||||
|
expect(Auth::guard('eleve')->attempt(['identifiant' => 'ines.m', 'password' => $code]))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un code faux ou une classe archivée sont refusés', function () {
|
||||||
|
$this->post(route('rejoindre.demander'), ['code' => 'ZZZZZZ', 'prenom' => 'Léo'])->assertSessionHasErrors('code');
|
||||||
|
|
||||||
|
$archivee = Classe::factory()->create();
|
||||||
|
$archivee->forceFill(['archivee_a' => now()])->save();
|
||||||
|
$this->post(route('rejoindre.demander'), ['code' => $archivee->code_classe, 'prenom' => 'Léo'])->assertSessionHasErrors('code');
|
||||||
|
|
||||||
|
expect(DemandeInscription::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une demande refusée l’annonce à l’élève et disparaît', function () {
|
||||||
|
$classe = Classe::factory()->create();
|
||||||
|
$this->post(route('rejoindre.demander'), ['code' => $classe->code_classe, 'prenom' => 'Léo']);
|
||||||
|
|
||||||
|
commeProf($this, $classe->prof)->post(route('mes-classes.demandes.refuser', [$classe, DemandeInscription::sole()]));
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
$this->get(route('rejoindre.attente'))->assertOk()->assertSee('pas accepté');
|
||||||
|
expect(DemandeInscription::count())->toBe(0)->and(Eleve::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seul le navigateur de l’élève voit son code, et seul le prof de la classe peut accepter', function () {
|
||||||
|
$classe = Classe::factory()->create();
|
||||||
|
$this->post(route('rejoindre.demander'), ['code' => $classe->code_classe, 'prenom' => 'Léo']);
|
||||||
|
$demande = DemandeInscription::sole();
|
||||||
|
|
||||||
|
commeProf($this, User::factory()->create())->post(route('mes-classes.demandes.accepter', [$classe, $demande]))->assertForbidden();
|
||||||
|
commeProf($this, $classe->prof)->post(route('mes-classes.demandes.accepter', [$classe, $demande]));
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
$this->flushSession();
|
||||||
|
$this->get(route('rejoindre.bienvenue'))->assertRedirect(route('rejoindre'));
|
||||||
|
expect(DemandeInscription::sole()->statut)->toBe(DemandeInscription::ACCEPTEE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une demande non traitée depuis une heure est supprimée', function () {
|
||||||
|
$classe = Classe::factory()->create();
|
||||||
|
$ancienne = DemandeInscription::create(['classe_id' => $classe->id, 'prenom' => 'A', 'jeton_empreinte' => DemandeInscription::empreinte('a'), 'statut' => 'en_attente']);
|
||||||
|
$ancienne->forceFill(['created_at' => now()->subMinutes(61)])->save();
|
||||||
|
DemandeInscription::create(['classe_id' => $classe->id, 'prenom' => 'B', 'jeton_empreinte' => DemandeInscription::empreinte('b'), 'statut' => 'en_attente']);
|
||||||
|
|
||||||
|
$this->artisan('model:prune', ['--model' => [DemandeInscription::class]]);
|
||||||
|
|
||||||
|
expect(DemandeInscription::pluck('prenom')->all())->toBe(['B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le prof génère de nouvelles fiches pour les élèves cochés, et seulement eux', function () {
|
||||||
|
$classe = Classe::factory()->create();
|
||||||
|
[$a, $b] = Eleve::factory()->count(2)->create(['classe_id' => $classe->id]);
|
||||||
|
$ancienB = $b->code_secret;
|
||||||
|
|
||||||
|
commeProf($this, $classe->prof)->post(route('mes-classes.fiches', $classe), ['eleves' => [$a->id]])
|
||||||
|
->assertSessionHas('fiches', fn ($fiches) => count($fiches) === 1 && $fiches[0]['identifiant'] === $a->identifiant);
|
||||||
|
|
||||||
|
expect(Auth::guard('eleve')->attempt(['identifiant' => $a->identifiant, 'password' => '123456']))->toBeFalse()
|
||||||
|
->and($b->refresh()->code_secret)->toBe($ancienB);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue