diff --git a/CLAUDE.md b/CLAUDE.md index c4bfd4b..890c0c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,9 @@ Le script tient un registre `schema_migration` (fichier + empreinte MD5) : un fi et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester idempotente** (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE VIEW`, `INSERT … ON DUPLICATE KEY UPDATE`). Les fichiers sont pris dans l'ordre alphabétique : nommer une -migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`. +migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`. C'est pourquoi la +migration de la V7.11 s'appelle `migration_v7_9e_dsi_comptes_journal.sql` : le préfixe suit la +dernière migration en date, pas le numéro de version fonctionnelle. `PTA_DB_PORT`, `DB_PORT` et `PMA_PORT` du compose sont les ports joints **à l'intérieur** du réseau Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte (`APP_PORT`, @@ -66,7 +68,8 @@ Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'h Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes. **Pages HTML** — `dashboard.php`, `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`, -`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `administration.php` : +`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `manage_agents.php`, +`manage_structures.php`, `administration.php`, `dsi.php` : ```php require __DIR__ . '/page_bootstrap.php'; @@ -119,19 +122,35 @@ l'application entière refuse de démarrer. ### Rôles et périmètres (`app/Core/Access.php`) -Trois rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition -des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout, seul habilité à valider). +Quatre rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition +des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout le métier, seul habilité à valider) et +`DSI` (administration technique : comptes d'accès et journal, aucun droit métier). -`role.php` est un **sélecteur de rôle, pas une authentification** — l'utilisateur choisit librement -son identité. C'est la faille ouverte la plus importante du projet ; toute évolution vers la -production doit remplacer cette sélection par un compte authentifié, sans toucher au reste de la -mécanique de droits. +`role.php` n'est plus un sélecteur depuis la V7.11 : c'est une **authentification par jeton** (voir +`README_V7_11.md`). Une session ne peut naître que de `Access::openSession()`, appelée après +vérification d'un jeton par `CompteModel::authentifier()`. Le rôle, l'agent et le lieu sont **lus sur +le compte**, jamais déclarés par l'utilisateur. `Access::select()` a disparu — ne pas le +réintroduire. + +Les comptes `SERVICE_ENFANCE` et `DSI` exigent un **second facteur** depuis la V7.12 (voir +`README_V7_12.md`) : après le jeton, un mot de passe choisi par le titulaire à sa première connexion +et haché par `password_hash()`. La règle « qui doit avoir un mot de passe » est portée par la vue +(`v_acces_compte.mot_de_passe_requis`), pas par du PHP. Entre les deux étapes, la session ne porte +qu'une attente horodatée de cinq minutes : **un jeton validé sans mot de passe n'ouvre rien**. La DSI +peut effacer un mot de passe, jamais en poser un — sans quoi elle connaîtrait celui de tout le monde. + +`Access` relit le compte à **chaque requête** (`v_acces_compte`, filtrée sur `actif` et +`identite_active`) : c'est ce qui fait qu'un jeton révoqué, un agent désactivé ou un lieu fermé +referment la session en cours. Ne pas mettre ce résultat en cache entre deux requêtes. **`Access.php` ne contient aucune règle de droit** : tout est lu dans les vues SQL créées par `database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de -la base vaut « refusé ». `SchemaModel` vérifie la présence des sept vues au démarrage et fait -échouer l'application si la migration manque — c'est volontairement fail-closed. +la base vaut « refusé » (les lignes du rôle DSI viennent, elles, de +`migration_v7_9e_dsi_comptes_journal.sql`). `SchemaModel` vérifie au démarrage la présence des sept +vues de droits et des trois vues de comptes/journal, et fait échouer l'application si une migration +manque — c'est volontairement fail-closed, et `docker/db/apply-migrations.sh` refait le même contrôle +au déploiement. `dashboard.php` (Service Enfance uniquement, voir `README_V7_9.md`) illustre la règle : le droit d'accès vient de `database/migration_v7_9_tableau_de_bord.sql`. Tant que la migration n'est pas @@ -139,14 +158,37 @@ jouée, l'onglet n'apparaît pas et `Access::homeUrl()` se replie sur `planning. indispensable, sans lui `requirePage()` renverrait indéfiniment vers une page interdite. Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur -commence par un garde `requireService()`, `requireDraftEditor()`, `requireStructureEdit($id)`, -`requireAgentView($id)`, `requireEntryEdit($id)`, `requirePlanningEdit($id)`… Ces gardes émettent +commence par un garde `requireService()`, `requireDsi()`, `requireDraftEditor()`, +`requireStructureEdit($id)`, `requireAgentView($id)`, `requireEntryEdit($id)`, +`requirePlanningEdit($id)`… Ces gardes émettent directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans garde ni sans `Csrf::assertPayload($payload)`.** CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS : `csrf_token: window.PTA_CONFIG.csrfToken`. +### Journalisation (V7.11) + +`App\Core\Journal` écrit dans `journal_activite`. Sa connexion PDO est posée une fois par +`app/bootstrap.php`, les appelants n'ont donc rien à transporter : + +```php +Journal::ecriture('PLANNING_VALIDE', 'planning', $planningId, 'Planning validé pour…', [contexte]); +``` + +Trois règles : + +- **tout endpoint mutant doit journaliser son succès**, après l'écriture en base et avant le + `JsonResponse::send()` — au même titre qu'il porte un garde et un `Csrf::assertPayload()` ; +- **les lectures ne sont jamais journalisées** (choix de volume) ; les refus le sont, et `Access` + comme `Csrf` s'en chargent déjà — inutile de les doubler dans les contrôleurs ; +- **le journal ne doit jamais faire échouer l'action** : `Journal` avale ses propres erreurs. Ne pas + entourer un appel de logique qui pourrait lever. + +Le journal sert aussi de compteur au frein anti-force-brute de la connexion +(`JournalModel::echecsRecents()`) : renommer les actions `JETON_REFUSE` ou `MOT_DE_PASSE_REFUSE` +désarmerait ce frein silencieusement. + ### Contraintes de sécurité à respecter - **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`. @@ -175,7 +217,7 @@ PTA.modules.agentOverview = { init, load }; applique les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`, `focus`). Les modules se rafraîchissent entre eux via l'appel optionnel `PTA.modules.autre?.refresh?.({ silent: true })`. -Les assets sont versionnés à la main : `assets/style.css?v=7.9` et `assets/js/*.js?v=7.9` dans +Les assets sont versionnés à la main : `assets/style.css?v=7.23` et `assets/js/*.js?v=7.23` dans `layouts/header.php` / `layouts/footer.php` **et dans `Views/pages/role.php`**, qui porte sa propre balise ``. **Bumper ce numéro aux trois endroits après toute modification de JS/CSS**, sinon les navigateurs et le `expires 7d` de nginx servent l'ancienne version. @@ -190,6 +232,13 @@ les navigateurs et le `expires 7d` de nginx servent l'ancienne version. imputer des heures de quota les décompterait une seconde fois. Une tentative de décompter un forfait de 7 h par jour de CP a été introduite puis annulée pour cette raison — ne pas la réintroduire. +- **Un agent n'est planifiable qu'entre ses dates de contrat** (V7.15, + `date_debut_contrat` / `date_fin_contrat`). La règle vaut pour TOUS les motifs, congés compris : + hors contrat, il n'y a rien à décompter. Une borne absente ouvre — pas de date de fin signifie + contrat à durée indéterminée. Elle est appliquée à l'écriture (`assertDatesWithinContract()` dans + `PlanningController` et `WeekTemplateController`), au contrôle de validation (`HORS_CONTRAT`) et à + l'inverse, sur la fiche agent : raccourcir un contrat sous des affectations existantes est refusé. + Toute nouvelle voie d'écriture de créneaux doit poser la même garde. - Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas). @@ -231,5 +280,8 @@ message}` : une `ERREUR` bloque (422), une `ALERTE` demande une confirmation exp `PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe. - Le mot de passe MariaDB du `.env` est encore la valeur d'exemple : à changer avant tout déploiement réel. +- Le **jeton DSI d'installation** (`PTA-INSTALLATION-DSI`) est écrit en clair dans + `database/migration_v7_9e_dsi_comptes_journal.sql`, donc public. La page DSI alerte tant qu'il n'a + pas été régénéré ; c'est la première action à faire après une installation. - Les `README_V7_*.md` documentent chacun une version successive et servent d'historique métier — utiles pour comprendre une décision, mais seul le code fait foi. diff --git a/api/dsi_compte_creer.php b/api/dsi_compte_creer.php new file mode 100644 index 0000000..fed0e28 --- /dev/null +++ b/api/dsi_compte_creer.php @@ -0,0 +1,20 @@ +creerCompte(); diff --git a/api/dsi_compte_jeton.php b/api/dsi_compte_jeton.php new file mode 100644 index 0000000..e1f3523 --- /dev/null +++ b/api/dsi_compte_jeton.php @@ -0,0 +1,19 @@ +regenererJeton(); diff --git a/api/dsi_compte_mot_de_passe.php b/api/dsi_compte_mot_de_passe.php new file mode 100644 index 0000000..b32caa7 --- /dev/null +++ b/api/dsi_compte_mot_de_passe.php @@ -0,0 +1,22 @@ +reinitialiserMotDePasse(); diff --git a/api/dsi_compte_statut.php b/api/dsi_compte_statut.php new file mode 100644 index 0000000..5b736e7 --- /dev/null +++ b/api/dsi_compte_statut.php @@ -0,0 +1,19 @@ +changerStatutCompte(); diff --git a/api/dsi_comptes.php b/api/dsi_comptes.php new file mode 100644 index 0000000..aba49fd --- /dev/null +++ b/api/dsi_comptes.php @@ -0,0 +1,19 @@ +comptes(); diff --git a/api/dsi_comptes_manquants.php b/api/dsi_comptes_manquants.php new file mode 100644 index 0000000..ab27cd4 --- /dev/null +++ b/api/dsi_comptes_manquants.php @@ -0,0 +1,20 @@ +genererJetonsManquants(); diff --git a/api/dsi_journal.php b/api/dsi_journal.php new file mode 100644 index 0000000..60687b0 --- /dev/null +++ b/api/dsi_journal.php @@ -0,0 +1,19 @@ +journal(); diff --git a/api/dsi_journal_purge.php b/api/dsi_journal_purge.php new file mode 100644 index 0000000..d78f9a6 --- /dev/null +++ b/api/dsi_journal_purge.php @@ -0,0 +1,19 @@ +purgerJournal(); diff --git a/app/Controllers/AgentController.php b/app/Controllers/AgentController.php index 1c6543d..04fd394 100644 --- a/app/Controllers/AgentController.php +++ b/app/Controllers/AgentController.php @@ -6,6 +6,7 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Csrf; +use App\Core\Journal; use App\Core\JsonResponse; use App\Core\PdfResponse; use App\Core\Request; @@ -414,6 +415,16 @@ final class AgentController ]); } + Journal::ecriture( + 'AGENT_RATTACHEMENT_MODIFIE', + 'agent', + (int) $agentId, + $structureId === null + ? sprintf('Lieu principal retiré à l’agent #%d.', (int) $agentId) + : sprintf('Lieu principal de l’agent #%d changé pour le lieu #%d.', (int) $agentId, (int) $structureId), + ['id_structure' => $structureId !== null ? (int) $structureId : null] + ); + JsonResponse::send(200, [ 'message' => $structureId === null ? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.' @@ -518,6 +529,14 @@ final class AgentController } $structure = $this->structureModel->findActive((int) $structureId); + Journal::ecriture( + 'AGENT_CREE', + 'agent', + isset($created['id_agent']) ? (int) $created['id_agent'] : null, + sprintf('Création de l’agent %s %s (matricule %s).', $prenom, $nom, $matricule), + ['matricule' => $matricule, 'id_structure' => (int) $structureId, 'type_contrat' => $contractType] + ); + JsonResponse::send(201, [ 'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']), 'agent' => array_merge($created, [ @@ -548,7 +567,9 @@ final class AgentController * POST api/update_agent.php — modification de la fiche d'un agent. * * Mêmes contrôles que create(), matricule excepté : celui-ci n'est jamais - * modifiable, c'est la clé de rapprochement avec les outils RH. + * modifiable, c'est la clé de rapprochement avec les outils RH. Le nom et le + * prénom, EUX, le sont (V7.13) : mariage, divorce, ou simple faute de frappe + * à la saisie initiale — sans cet écran, il fallait passer par SQL. * * La quotité de travail n'est pas modifiable ici non plus : elle détermine * le quota annuel déjà consommé, et la changer en cours d'année fausserait @@ -562,6 +583,8 @@ final class AgentController Csrf::assertPayload($payload); $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); + $nom = trim((string) ($payload['nom'] ?? '')); + $prenom = trim((string) ($payload['prenom'] ?? '')); $email = trim((string) ($payload['email'] ?? '')); $telephone = trim((string) ($payload['telephone'] ?? '')); $adresse = trim((string) ($payload['adresse'] ?? '')); @@ -586,9 +609,15 @@ final class AgentController if (!$agentId) { JsonResponse::send(422, ['error' => 'Agent invalide.']); } - if ($this->agentModel->findActive((int) $agentId) === null) { + // La fiche AVANT modification est conservée : elle sert à repérer un + // renommage et à le nommer explicitement dans le journal. + $agentAvant = $this->agentModel->findActive((int) $agentId); + if ($agentAvant === null) { JsonResponse::send(404, ['error' => 'Agent introuvable.']); } + if ($nom === '' || $prenom === '') { + JsonResponse::send(422, ['error' => 'Le nom et le prénom de l’agent sont obligatoires.']); + } if (!$postId || !in_array((int) $postId, $validPostIds, true) || !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true) || !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart @@ -598,15 +627,43 @@ final class AgentController if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) { JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']); } - if (strlen($email) > 255 || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) { + if (strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255 || strlen($telephone) > 30 + || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) { JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']); } if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) { JsonResponse::send(422, ['error' => 'Lieu d’affectation principal invalide ou inactif.']); } + // Contrôle MIROIR de celui posé à l'affectation (V7.15) : interdire de + // planifier hors contrat ne sert à rien si l'on peut ensuite déplacer le + // contrat sous des affectations existantes. Le refus nomme la période + // concernée, pour que l'utilisateur sache quoi retirer avant de + // recommencer. + $orphelins = $this->agentModel->entriesOutsideContract( + (int) $agentId, + $contractStart, + $contractEnd !== '' ? $contractEnd : null + ); + if ($orphelins['nombre'] > 0) { + $lisible = static fn(?string $date): string => $date === null + ? '—' + : implode('/', array_reverse(explode('-', $date))); + JsonResponse::send(422, [ + 'error' => sprintf( + 'Ces dates de contrat laisseraient %d affectation(s) en dehors du contrat, entre le %s et le %s. Supprimez-les d’abord, ou élargissez les dates.', + $orphelins['nombre'], + $lisible($orphelins['premiere']), + $lisible($orphelins['derniere']) + ), + 'code' => 'HORS_CONTRAT', + ]); + } + try { $this->agentModel->updateProfile((int) $agentId, [ + 'nom' => $nom, + 'prenom' => $prenom, 'email' => $email !== '' ? $email : null, 'telephone' => $telephone !== '' ? $telephone : null, 'adresse' => $adresse !== '' ? $adresse : null, @@ -627,9 +684,33 @@ final class AgentController ]); } + // La fiche est relue une seule fois : elle sert à la fois au libellé de + // la trace et à la réponse. + $agent = $this->agentModel->findActive((int) $agentId); + + $identiteAvant = trim((string) $agentAvant['prenom'] . ' ' . (string) $agentAvant['nom']); + $identiteApres = trim($prenom . ' ' . $nom); + $renomme = $identiteAvant !== $identiteApres; + + Journal::ecriture( + 'AGENT_MODIFIE', + 'agent', + (int) $agentId, + $renomme + // Un renommage doit se lire dans le journal sans avoir à + // comparer deux lignes : l'ancienne identité y figure en clair. + ? sprintf('Modification de la fiche de l’agent %s, renommé depuis « %s ».', $identiteApres, $identiteAvant) + : sprintf('Modification de la fiche de l’agent %s.', $identiteApres), + [ + 'id_structure' => $structureId !== null ? (int) $structureId : null, + 'type_contrat' => $contractType, + 'identite_precedente' => $renomme ? $identiteAvant : null, + ] + ); + JsonResponse::send(200, [ 'message' => 'Les informations de l’agent ont été mises à jour.', - 'agent' => $this->agentModel->findActive((int) $agentId), + 'agent' => $agent, ]); } diff --git a/app/Controllers/AnnualOverviewController.php b/app/Controllers/AnnualOverviewController.php index 175d19c..a871093 100644 --- a/app/Controllers/AnnualOverviewController.php +++ b/app/Controllers/AnnualOverviewController.php @@ -6,6 +6,7 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Csrf; +use App\Core\Journal; use App\Core\JsonResponse; use App\Core\Request; use App\Models\AnnualOverviewModel; @@ -164,6 +165,19 @@ final class AnnualOverviewController $ids = array_map(static fn(array $planning): int => (int) $planning['id_planning'], $plannings); $validatedCount = $planningModel->markValidatedMany($ids); + Journal::ecriture( + 'ANNEE_VALIDEE', + 'planning', + null, + sprintf('%d planning(s) validés d’un coup pour l’agent #%d, année %d.', $validatedCount, (int) $agentId, (int) $year), + [ + 'id_agent' => (int) $agentId, + 'annee' => (int) $year, + 'id_plannings' => $ids, + 'quota_force' => $forceQuota, + ] + ); + JsonResponse::send(200, [ 'success' => true, 'status' => 'validated', @@ -211,6 +225,19 @@ final class AnnualOverviewController $this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.'); } + foreach ($model->entriesOutsideContract($planningId) as $row) { + $this->addControl( + $controls, + 'ERREUR', + 'HORS_CONTRAT', + sprintf( + 'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.', + $row['id_creneau'], + $row['date_jour'] + ) + ); + } + foreach ($model->entriesOutsideWeek($planningId, (string) $planning['date_debut'], (string) $planning['date_fin']) as $row) { $this->addControl( $controls, diff --git a/app/Controllers/CoverageController.php b/app/Controllers/CoverageController.php index 1b70f17..df431fa 100644 --- a/app/Controllers/CoverageController.php +++ b/app/Controllers/CoverageController.php @@ -6,6 +6,7 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Csrf; +use App\Core\Journal; use App\Core\JsonResponse; use App\Core\Request; use App\Models\CoverageModel; @@ -221,6 +222,14 @@ final class CoverageController ]); } + Journal::ecriture( + 'COUVERTURE_ENREGISTREE', + 'structure_couverture_horaire', + (int) $structureId, + sprintf('Horaires de couverture du lieu #%d enregistrés : %d plage(s).', (int) $structureId, count($rules)), + ['id_structure' => (int) $structureId, 'nombre_plages' => count($rules)] + ); + JsonResponse::send(200, [ 'message' => 'Les horaires de couverture du lieu ont été enregistrés.', 'rules' => $this->coverageModel->rulesForStructure((int) $structureId), diff --git a/app/Controllers/DsiController.php b/app/Controllers/DsiController.php new file mode 100644 index 0000000..f218c28 --- /dev/null +++ b/app/Controllers/DsiController.php @@ -0,0 +1,394 @@ +access = new Access($pdo); + $this->comptes = new CompteModel($pdo); + $this->journal = new JournalModel($pdo); + } + + /** + * GET api/dsi_comptes.php — comptes d'accès et identités sans compte. + * + * Paramètres GET : role, actif, q. + */ + public function comptes(): void + { + $this->access->requireDsi(); + + $comptes = $this->comptes->lister([ + 'role' => (string) ($_GET['role'] ?? ''), + 'actif' => (string) ($_GET['actif'] ?? ''), + 'q' => (string) ($_GET['q'] ?? ''), + ]); + + JsonResponse::send(200, [ + 'comptes' => $comptes, + 'manquants' => $this->comptes->manquants(), + // Tant qu'un compte porte encore le jeton public de la migration, + // l'interface doit le crier : c'est une porte ouverte documentée. + 'jeton_installation_actif' => $this->comptes->installationEnCours(), + 'roles' => $this->rolesDisponibles(), + ]); + } + + /** + * POST api/dsi_compte_creer.php — crée un compte et renvoie son jeton. + * + * Charge utile : role, agent_id, structure_id, libelle, commentaire, + * csrf_token. + */ + public function creerCompte(): void + { + $this->access->requireDsi(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $role = strtoupper(trim((string) ($payload['role'] ?? ''))); + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null; + $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null; + $libelle = trim((string) ($payload['libelle'] ?? '')); + $commentaire = trim((string) ($payload['commentaire'] ?? '')); + + try { + $resultat = $this->comptes->creer( + $role, + $agentId !== null ? (int) $agentId : null, + $structureId !== null ? (int) $structureId : null, + $libelle, + $commentaire !== '' ? $commentaire : null + ); + } catch (DomainException $e) { + JsonResponse::send(422, ['error' => $e->getMessage()]); + return; + } + + $compte = $resultat['compte']; + Journal::administration('COMPTE_CREE', [ + 'cible_table' => 'compte_acces', + 'cible_id' => (int) $compte['id_compte'], + 'description' => sprintf( + 'Création du compte d’accès « %s » (%s).', + (string) $compte['identite_libelle'], + (string) $compte['role'] + ), + 'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']], + ]); + + JsonResponse::send(201, [ + 'message' => sprintf('Compte créé pour %s. Transmettez le jeton : il ne sera plus affiché.', (string) $compte['identite_libelle']), + 'compte' => $compte, + 'jeton' => $resultat['jeton'], + ]); + } + + /** + * POST api/dsi_compte_jeton.php — régénère le jeton d'un compte. + * + * L'ancien jeton cesse de fonctionner immédiatement : c'est la procédure à + * suivre pour un jeton perdu comme pour un jeton compromis. + * + * Charge utile : compte_id, csrf_token. + */ + public function regenererJeton(): void + { + $this->access->requireDsi(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0); + + try { + $jeton = $this->comptes->regenererJeton($compteId); + } catch (DomainException $e) { + JsonResponse::send(404, ['error' => $e->getMessage()]); + return; + } + + $compte = $this->comptes->trouver($compteId) ?? []; + Journal::administration('JETON_REGENERE', [ + 'cible_table' => 'compte_acces', + 'cible_id' => $compteId, + 'description' => sprintf( + 'Régénération du jeton de « %s » : l’ancien jeton est révoqué.', + (string) ($compte['identite_libelle'] ?? 'compte inconnu') + ), + 'contexte' => ['prefixe' => $compte['jeton_prefixe'] ?? null], + ]); + + JsonResponse::send(200, [ + 'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.', + 'compte' => $compte, + 'jeton' => $jeton, + ]); + } + + /** + * POST api/dsi_compte_mot_de_passe.php — efface le mot de passe d'un compte + * privilégié. + * + * La DSI ne REMPLACE pas le mot de passe, elle le RETIRE : le titulaire en + * choisira un nouveau à sa prochaine connexion, avec son jeton. C'est ce qui + * garantit que personne d'autre que lui ne l'a jamais connu — y compris la + * personne qui vient de le réinitialiser. + * + * Charge utile : compte_id, csrf_token. + */ + public function reinitialiserMotDePasse(): void + { + $this->access->requireDsi(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0); + + try { + $compte = $this->comptes->reinitialiserMotDePasse($compteId); + } catch (DomainException $e) { + JsonResponse::send(422, ['error' => $e->getMessage()]); + return; + } + + Journal::administration('MOT_DE_PASSE_REINITIALISE', [ + 'cible_table' => 'compte_acces', + 'cible_id' => $compteId, + 'description' => sprintf( + 'Mot de passe de « %s » effacé : le titulaire en définira un nouveau à sa prochaine connexion.', + (string) $compte['identite_libelle'] + ), + 'contexte' => ['role' => $compte['role']], + ]); + + JsonResponse::send(200, [ + 'message' => sprintf( + 'Mot de passe effacé pour %s. Son jeton reste valable : il en choisira un nouveau à sa prochaine connexion.', + (string) $compte['identite_libelle'] + ), + ]); + } + + /** + * POST api/dsi_compte_statut.php — active, désactive ou supprime un compte. + * + * La suppression efface le compte mais PAS ses traces : le journal conserve + * le libellé figé de l'acteur (clé étrangère en ON DELETE SET NULL). + * + * Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER), + * csrf_token. + */ + public function changerStatutCompte(): void + { + $this->access->requireDsi(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0); + $action = strtoupper(trim((string) ($payload['action'] ?? ''))); + + if (!in_array($action, ['ACTIVER', 'DESACTIVER', 'SUPPRIMER'], true)) { + JsonResponse::send(422, ['error' => 'Action inconnue.']); + } + + // La fiche est lue AVANT l'opération : après une suppression, il n'y + // aurait plus de libellé à écrire dans le journal. + $compte = $this->comptes->trouver($compteId); + if ($compte === null) { + JsonResponse::send(404, ['error' => 'Compte introuvable.']); + return; + } + + // Se retirer soi-même son propre accès en pleine session est une erreur + // de manipulation, jamais une intention : on la refuse. + if ($compteId === $this->access->accountId() && $action !== 'ACTIVER') { + JsonResponse::send(422, ['error' => 'Vous ne pouvez pas désactiver ni supprimer le compte avec lequel vous êtes connecté.']); + } + + try { + if ($action === 'SUPPRIMER') { + $this->comptes->supprimer($compteId); + } else { + $this->comptes->definirActif($compteId, $action === 'ACTIVER'); + } + } catch (DomainException $e) { + JsonResponse::send(422, ['error' => $e->getMessage()]); + return; + } + + $libelles = [ + 'ACTIVER' => 'Réactivation', + 'DESACTIVER' => 'Désactivation', + 'SUPPRIMER' => 'Suppression', + ]; + + Journal::administration('COMPTE_' . $action, [ + 'cible_table' => 'compte_acces', + 'cible_id' => $compteId, + 'description' => sprintf( + '%s du compte d’accès « %s » (%s).', + $libelles[$action], + (string) $compte['identite_libelle'], + (string) $compte['role'] + ), + 'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']], + ]); + + JsonResponse::send(200, [ + 'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']), + ]); + } + + /** + * POST api/dsi_comptes_manquants.php — équipe d'un coup toutes les identités + * actives sans compte. + * + * C'est l'outil de MISE EN SERVICE : à l'installation, aucun agent ni + * responsable n'a de jeton, donc personne ne peut entrer. La réponse porte + * tous les jetons produits, en clair — c'est la seule occasion de les lire, + * et elle est faite pour être imprimée ou copiée avant de refermer l'écran. + * + * Charge utile : csrf_token. + */ + public function genererJetonsManquants(): void + { + $this->access->requireDsi(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + try { + $produits = $this->comptes->genererManquants(); + } catch (DomainException $e) { + JsonResponse::send(422, ['error' => $e->getMessage()]); + return; + } + + Journal::administration('JETONS_GENERES_EN_LOT', [ + 'cible_table' => 'compte_acces', + 'cible_id' => null, + 'description' => sprintf('Génération de %d jeton(s) pour les identités sans compte.', count($produits)), + 'contexte' => ['nombre' => count($produits)], + ]); + + JsonResponse::send(200, [ + 'message' => $produits === [] + ? 'Toutes les identités actives disposent déjà d’un compte.' + : sprintf('%d jeton(s) généré(s). Distribuez-les maintenant : ils ne seront plus affichés.', count($produits)), + 'jetons' => $produits, + ]); + } + + /** + * GET api/dsi_journal.php — recherche paginée dans le journal. + * + * Paramètres GET : categorie, resultat, role, id_compte, q, du, au, page, + * taille. + */ + public function journal(): void + { + $this->access->requireDsi(); + + $resultat = $this->journal->rechercher([ + 'categorie' => (string) ($_GET['categorie'] ?? ''), + 'resultat' => (string) ($_GET['resultat'] ?? ''), + 'role' => (string) ($_GET['role'] ?? ''), + 'id_compte' => (int) ($_GET['id_compte'] ?? 0), + 'q' => (string) ($_GET['q'] ?? ''), + 'du' => (string) ($_GET['du'] ?? ''), + 'au' => (string) ($_GET['au'] ?? ''), + 'page' => (int) ($_GET['page'] ?? 1), + 'taille' => (int) ($_GET['taille'] ?? JournalModel::TAILLE_PAGE), + ]); + + JsonResponse::send(200, array_merge($resultat, [ + 'statistiques' => $this->journal->statistiques(), + ])); + } + + /** + * POST api/dsi_journal_purge.php — supprime les entrées les plus anciennes. + * + * La purge est elle-même journalisée, et l'est APRÈS coup : sa propre trace + * ne peut donc pas être emportée par la purge qu'elle décrit. + * + * Charge utile : jours (minimum 30), csrf_token. + */ + public function purgerJournal(): void + { + $this->access->requireDsi(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $jours = (int) (filter_var($payload['jours'] ?? null, FILTER_VALIDATE_INT) ?: 0); + if ($jours < 30) { + JsonResponse::send(422, ['error' => 'La purge ne peut pas descendre sous 30 jours de conservation.']); + } + + $supprimees = $this->journal->purger($jours); + + Journal::administration('JOURNAL_PURGE', [ + 'cible_table' => 'journal_activite', + 'cible_id' => null, + 'description' => sprintf('Purge du journal : %d entrée(s) de plus de %d jours supprimée(s).', $supprimees, $jours), + 'contexte' => ['jours' => $jours, 'supprimees' => $supprimees], + ]); + + JsonResponse::send(200, [ + 'message' => sprintf('%d entrée(s) supprimée(s).', $supprimees), + 'supprimees' => $supprimees, + ]); + } + + /** + * Rôles proposés à la création d'un compte, avec leur libellé. + * + * @return array + */ + private function rolesDisponibles(): array + { + return [ + ['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'], + ['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'], + ['code' => Access::ROLE_SERVICE, 'libelle' => 'Service Enfance (administrateur)', 'identite' => 'LIBELLE'], + ['code' => Access::ROLE_DSI, 'libelle' => 'DSI', 'identite' => 'LIBELLE'], + ]; + } +} diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php index 80fa2d0..5cf7d21 100644 --- a/app/Controllers/PageController.php +++ b/app/Controllers/PageController.php @@ -254,12 +254,83 @@ final class PageController extends Controller { $this->access->requirePage('administration'); $this->renderPage('administration', 'Administration', 'administration', [ - 'pageSubtitle' => 'Les référentiels de PTA : agents, lieux d’affectation, postes et horaires de couverture attendus.', - 'agents' => (new AgentModel($this->pdo))->allActive(), + 'pageSubtitle' => 'Les horaires de couverture attendus de chaque lieu : les plages où un effectif minimum doit être présent.', + // Depuis la V7.14, cet écran ne porte plus que les règles de + // couverture : agents et lieux ont leur propre page (management). + // Seule la liste des lieux reste nécessaire, pour son sélecteur. 'structures' => (new StructureModel($this->pdo))->allActive(), - 'posts' => (new AgentModel($this->pdo))->allPosts(), - 'scripts' => ['administration.js', 'coverage.js'], - 'modules' => ['administration', 'coverage'], + 'scripts' => ['coverage.js'], + 'modules' => ['coverage'], + ]); + } + + /** + * Management — Agents (V7.14) : création, liste et fiche des agents. + * + * Ce référentiel partageait l'écran « Administration » avec celui des lieux + * et les horaires de couverture, trois objets sans rapport. Chacun a + * désormais son onglet, réunis sous le groupe « Management » de la barre de + * navigation. + * + * NOM DE MÉTHODE EN SNAKE_CASE, à rebours du reste du projet : le nom de la + * méthode EST la clé de page passée à runPage() et inscrite dans + * acces_role_page, où les clés sont en minuscules. Les garder identiques + * prime sur l'uniformité de style — voir l'en-tête de cette classe. + * + * La liste des lieux est nécessaire ici aussi : le formulaire de création + * demande le lieu principal de l'agent. + */ + public function manage_agents(): void + { + $this->access->requirePage('manage_agents'); + $agentModel = new AgentModel($this->pdo); + $this->renderPage('manage_agents', 'Agents', 'manage_agents', [ + 'pageSubtitle' => 'Créer un agent, corriger sa fiche, changer son lieu principal de rattachement.', + 'agents' => $agentModel->allActive(), + 'structures' => (new StructureModel($this->pdo))->allActive(), + 'posts' => $agentModel->allPosts(), + 'scripts' => ['administration.js'], + 'modules' => ['administration'], + ]); + } + + /** + * Management — Lieux d'affectation (V7.14) : création et paramètres. + * + * Les horaires de couverture d'un lieu restent sur « Administration » : ils + * ne décrivent pas le lieu mais une exigence de présence. + */ + public function manage_structures(): void + { + $this->access->requirePage('manage_structures'); + $this->renderPage('manage_structures', 'Lieux d’affectation', 'manage_structures', [ + 'pageSubtitle' => 'Créer un lieu d’affectation et définir son type : périscolaire ou extrascolaire.', + 'structures' => (new StructureModel($this->pdo))->allActive(), + 'scripts' => ['administration.js'], + 'modules' => ['administration'], + ]); + } + + /** + * Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité. + * + * Page d'administration TECHNIQUE, ouverte au seul rôle DSI par + * acces_role_page. Elle ne charge aucun référentiel métier — la DSI n'a rien + * à connaître des agents ni des plannings ; les seules listes qu'elle + * manipule (identités sans compte) sont servies par l'API. + */ + public function dsi(): void + { + $this->access->requirePage('dsi'); + $this->renderPage('dsi', 'Espace DSI', 'dsi', [ + 'pageSubtitle' => 'Les comptes d’accès de l’application, leurs jetons, et le journal des connexions, des écritures et des refus.', + 'scripts' => ['dsi.js'], + 'modules' => ['dsi'], + 'pageConfig' => [ + // Lien profond « journal de ce compte » : filtre d'emblée le + // journal sur le compte désigné. + 'compteId' => $this->positiveInt($_GET['compte_id'] ?? null), + ], ]); } diff --git a/app/Controllers/PlanningController.php b/app/Controllers/PlanningController.php index 5dff179..62fb1a4 100644 --- a/app/Controllers/PlanningController.php +++ b/app/Controllers/PlanningController.php @@ -6,6 +6,7 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Csrf; +use App\Core\Journal; use App\Core\JsonResponse; use App\Core\MotifCategories; use App\Core\Request; @@ -164,6 +165,7 @@ final class PlanningController // affectable à un agent sans droit à l'enveloppe de formation. $this->assertMotifsAllowedForAgent($rawEntries, $agent); $entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent)); + $this->assertDatesWithinContract($agent, array_column($entries, 'date')); $this->assertNoInternalOverlap($entries); $this->assertNoOtherLocationOverlap( $entries, @@ -193,6 +195,14 @@ final class PlanningController ]); } + Journal::ecriture( + 'PLANNING_ENREGISTRE', + 'planning', + $planningId, + sprintf('Brouillon enregistré : %d créneau(x) pour l’agent #%d, semaine %s.', count($entries), (int) $agentId, $weekValue), + ['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'semaine' => $weekValue] + ); + JsonResponse::send(200, [ 'success' => true, 'planning_id' => $planningId, @@ -264,6 +274,7 @@ final class PlanningController if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) { JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']); } + $this->assertDatesWithinContract($entryAgent, [$date]); try { $result = $this->planningModel->updateEntryFromAgentView( @@ -284,6 +295,14 @@ final class PlanningController } $reopened = count($result['reopened_planning_ids']) > 0; + Journal::ecriture( + 'CRENEAU_MODIFIE', + 'creneau_horaire', + (int) $entryId, + sprintf('Créneau du %s modifié (%s-%s) pour l’agent #%d.', $date, $start, $end, (int) $result['agent_id']), + ['id_agent' => (int) $result['agent_id'], 'id_structure' => (int) $structureId, 'revalidation' => $reopened] + ); + JsonResponse::send(200, [ 'success' => true, 'message' => $reopened @@ -337,6 +356,14 @@ final class PlanningController $message .= ' Le planning validé concerné a été repassé en brouillon.'; } + Journal::ecriture( + 'CRENEAU_SUPPRIME', + 'creneau_horaire', + (int) $entryId, + sprintf('Créneau du %s supprimé pour l’agent #%d.', (string) $result['date'], (int) $result['agent_id']), + ['id_agent' => (int) $result['agent_id'], 'revalidation' => (bool) $result['reopened']] + ); + JsonResponse::send(200, [ 'success' => true, 'message' => $message, @@ -383,6 +410,12 @@ final class PlanningController if (!$validDate || (int) $dateObj->format('N') > 5) { JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']); } + + $entryAgent = $this->agentModel->findActive((int) $entry['id_agent']); + if ($entryAgent === null) { + JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']); + } + $this->assertDatesWithinContract($entryAgent, [$targetDate]); try { $result = $this->planningModel->moveOrDuplicateEntry( (int) $entryId, @@ -410,6 +443,14 @@ final class PlanningController $message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.'; } + Journal::ecriture( + $result['action'] === 'duplicate' ? 'CRENEAU_DUPLIQUE' : 'CRENEAU_DEPLACE', + 'creneau_horaire', + (int) $entryId, + sprintf('Créneau reporté au %s pour l’agent #%d.', $targetDate, (int) $result['agent_id']), + ['id_agent' => (int) $result['agent_id'], 'action' => $result['action'], 'revalidation' => $reopened] + ); + JsonResponse::send(200, [ 'success' => true, 'action' => $result['action'], @@ -448,9 +489,15 @@ final class PlanningController if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) { JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']); } - if ($this->agentModel->findActive((int) $agentId) === null) { + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); } + // La copie ne connaît pas d'avance les jours réellement recopiés : on + // contrôle les cinq jours ouvrés de la semaine cible, ce qui refuse une + // semaine à cheval sur la fin d'un contrat aussi bien qu'une semaine + // entièrement hors contrat. + $this->assertDatesWithinContract($agent, $this->workingDaysOfWeek($targetWeek[0], $targetWeek[1])); try { $result = $this->planningModel->copyAgentWeek( @@ -479,6 +526,25 @@ final class PlanningController $message .= ' Les plannings validés modifiés ont été repassés en brouillon.'; } + Journal::ecriture( + 'SEMAINE_COPIEE', + 'planning', + null, + sprintf( + '%d créneau(x) copiés de la semaine %s vers %s pour l’agent #%d.', + (int) $result['copied_entries'], + $sourceWeekValue, + $targetWeekValue, + (int) $agentId + ), + [ + 'id_agent' => (int) $agentId, + 'semaine_source' => $sourceWeekValue, + 'semaine_cible' => $targetWeekValue, + 'mode' => $mode, + ] + ); + JsonResponse::send(200, [ 'success' => true, 'message' => $message, @@ -531,6 +597,10 @@ final class PlanningController * - AUCUN_CRENEAU : planning vide ; * - CRENEAU_HORS_SEMAINE : créneau hors des bornes de la semaine ; * - CHEVAUCHEMENT_INTERNE : deux créneaux du planning se recouvrent ; + * - HORS_CONTRAT : affectation antérieure à l'arrivée de l'agent ou + * postérieure à son départ (V7.15). Les gardes d'écriture empêchent d'en + * créer, ce contrôle attrape celles qui préexistaient ou qu'un contrat + * raccourci a rendues illégitimes ; * - CHEVAUCHEMENT_AUTRE_STRUCTURE : recouvrement avec un créneau DÉJÀ * VALIDÉ sur un autre lieu. Seuls les plannings validés sont opposables : * un brouillon concurrent peut encore être corrigé, le bloquer @@ -569,6 +639,19 @@ final class PlanningController $this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.'); } + foreach ($this->planningModel->entriesOutsideContract((int) $planningId) as $row) { + $this->addControl( + $controls, + 'ERREUR', + 'HORS_CONTRAT', + sprintf( + 'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.', + $row['id_creneau'], + $row['date_jour'] + ) + ); + } + foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) { $this->addControl( $controls, @@ -643,6 +726,25 @@ final class PlanningController $this->planningModel->markValidated((int) $planningId); $this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.'); + // Journalisé APRÈS le passage en base : une validation refusée par les + // contrôles métier plus haut n'a pas eu lieu, elle n'a rien à tracer. + Journal::ecriture( + 'PLANNING_VALIDE', + 'planning', + (int) $planningId, + sprintf( + 'Planning validé pour l’agent #%d, semaine du %s.', + (int) $planning['id_agent'], + (string) $planning['date_debut'] + ), + [ + 'id_agent' => (int) $planning['id_agent'], + 'id_structure' => isset($planning['id_structure']) ? (int) $planning['id_structure'] : null, + 'date_debut' => (string) $planning['date_debut'], + 'quota_force' => $forceQuota, + ] + ); + JsonResponse::send(200, [ 'success' => true, 'status' => 'validated', @@ -687,6 +789,18 @@ final class PlanningController JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']); } + Journal::ecriture( + 'PLANNING_ROUVERT', + 'planning', + (int) $planningId, + sprintf( + 'Planning repassé en brouillon pour l’agent #%d, semaine du %s.', + (int) $planning['id_agent'], + (string) $planning['date_debut'] + ), + ['id_agent' => (int) $planning['id_agent'], 'date_debut' => (string) $planning['date_debut']] + ); + JsonResponse::send(200, [ 'success' => true, 'status' => 'BROUILLON', @@ -892,6 +1006,52 @@ final class PlanningController * @param array $controls * @param string $level ERREUR (bloquant), ALERTE (confirmable) ou INFO. */ + /** + * Refuse toute affectation hors des dates de contrat de l'agent (V7.15). + * + * Un agent n'existe pour PTA qu'entre sa date de début et sa date de fin de + * contrat : le planifier avant son arrivée ou après son départ produit des + * heures que personne ne fera, et fausse les cumuls annuels comme les + * contrôles de couverture. La règle s'applique à TOUS les motifs, congés et + * absences compris — hors contrat, il n'y a rien à décompter. + * + * Toutes les dates sont contrôlées d'un coup pour que l'utilisateur voie + * l'ensemble des jours refusés, et non le premier. + * + * @param array $agent Fiche agent. + * @param array $dates Dates AAAA-MM-JJ visées par l'écriture. + */ + private function assertDatesWithinContract(array $agent, array $dates): void + { + $hors = AgentModel::datesOutsideContract($agent, $dates); + if ($hors === []) { + return; + } + + JsonResponse::send(422, [ + 'error' => AgentModel::contractViolationMessage($agent, $hors), + 'code' => 'HORS_CONTRAT', + 'dates' => $hors, + ]); + } + + /** + * Les cinq jours ouvrés d'une semaine ISO, au format AAAA-MM-JJ. + * Le modèle métier s'arrête au vendredi : samedi et dimanche n'ont pas à + * être contrôlés, aucun créneau ne peut y être posé. + * + * @return array + */ + private function workingDaysOfWeek(int $year, int $weekNumber): array + { + $lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1); + + return array_map( + static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'), + range(0, 4) + ); + } + private function addControl(array &$controls, string $level, string $code, string $message): void { $controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message]; diff --git a/app/Controllers/PtaController.php b/app/Controllers/PtaController.php index d041fb9..3cdddca 100644 --- a/app/Controllers/PtaController.php +++ b/app/Controllers/PtaController.php @@ -6,6 +6,7 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Csrf; +use App\Core\Journal; use App\Core\JsonResponse; use App\Core\Request; use App\Models\AgentModel; @@ -111,6 +112,15 @@ final class PtaController JsonResponse::send(422, ['error' => 'Contrainte invalide.']); } $deleted = $this->ptaModel->deleteConstraint((int) $constraintId, (int) $agentId); + if ($deleted) { + Journal::ecriture( + 'CONTRAINTE_SUPPRIMEE', + 'agent_contrainte', + (int) $constraintId, + sprintf('Contrainte prioritaire #%d supprimée pour l’agent #%d.', (int) $constraintId, (int) $agentId), + ['id_agent' => (int) $agentId] + ); + } JsonResponse::send($deleted ? 200 : 404, [ 'message' => $deleted ? 'La contrainte a été supprimée.' : 'Contrainte introuvable.', ]); @@ -155,6 +165,13 @@ final class PtaController 'interdit_soir' => !empty($payload['interdit_soir']) ? 1 : 0, 'commentaire' => $comment, ]); + Journal::ecriture( + 'CONTRAINTE_ENREGISTREE', + 'agent_contrainte', + (int) $id, + sprintf('Contrainte « %s » enregistrée pour l’agent #%d à partir du %s.', $type, (int) $agentId, $start), + ['id_agent' => (int) $agentId, 'type_contrainte' => $type, 'date_debut' => $start, 'date_fin' => $end] + ); JsonResponse::send(201, ['message' => 'La contrainte prioritaire a été enregistrée.', 'id_contrainte' => $id]); } catch (Throwable $e) { // INCOHÉRENCE CONNUE : le détail de l'exception est renvoyé au @@ -196,6 +213,15 @@ final class PtaController JsonResponse::send(422, ['error' => 'Préférence invalide.']); } $deleted = $this->ptaModel->deleteWish((int) $wishId, (int) $agentId); + if ($deleted) { + Journal::ecriture( + 'SOUHAIT_SUPPRIME', + 'agent_structure_souhait', + (int) $wishId, + sprintf('Préférence de lieu #%d supprimée pour l’agent #%d.', (int) $wishId, (int) $agentId), + ['id_agent' => (int) $agentId] + ); + } JsonResponse::send($deleted ? 200 : 404, [ 'message' => $deleted ? 'La préférence ou interdiction a été supprimée.' : 'Élément introuvable.', ]); @@ -224,6 +250,13 @@ final class PtaController 'distance_km' => $distance, 'commentaire' => $comment !== '' ? $comment : null, ]); + Journal::ecriture( + 'SOUHAIT_ENREGISTRE', + 'agent_structure_souhait', + (int) $id, + sprintf('Préférence « %s » enregistrée pour l’agent #%d sur le lieu #%d.', $type, (int) $agentId, (int) $structureId), + ['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'type_souhait' => $type] + ); JsonResponse::send(201, ['message' => 'La préférence de lieu a été enregistrée.', 'id_souhait' => $id]); } catch (Throwable $e) { JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la préférence.', 'details' => $e->getMessage()]); diff --git a/app/Controllers/RoleController.php b/app/Controllers/RoleController.php index 1aad8fe..70ed602 100644 --- a/app/Controllers/RoleController.php +++ b/app/Controllers/RoleController.php @@ -7,90 +7,438 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Controller; use App\Core\Csrf; +use App\Core\Journal; use App\Core\Security; -use App\Models\AgentModel; -use App\Models\StructureModel; +use App\Core\Session; +use App\Models\CompteModel; +use App\Models\JournalModel; +use DomainException; use PDO; /** - * Sélection du rôle et de l'identité de travail. + * Connexion — point d'entrée de l'application (role.php). * - * ATTENTION : ce n'est PAS une authentification. L'utilisateur choisit - * librement qui il est, sans mot de passe ni vérification. C'est la faille - * ouverte la plus importante du projet, assumée pour cette version. Un passage - * en production doit remplacer cet écran par une authentification réelle — le - * reste de la mécanique de droits (Access, vues SQL) n'a pas à bouger, il - * suffit que la session soit renseignée par un compte vérifié. + * JUSQU'À LA V7.10, CET ÉCRAN ÉTAIT UN SÉLECTEUR LIBRE : chacun choisissait son + * rôle et son identité dans une liste, sans rien prouver. Depuis la V7.11, une + * session ne peut naître que de la présentation d'un JETON valide, distribué par + * la DSI. Le reste de la mécanique de droits n'a pas bougé : Access lit toujours + * les mêmes vues SQL, simplement le rôle n'est plus déclaré par l'utilisateur. * - * Contrairement aux autres pages, ce contrôleur n'est pas appelé par runPage() : - * il s'exécute AVANT tout contrôle d'accès, puisqu'il est ce qui permet d'en - * avoir un. + * La V7.12 ajoute un SECOND FACTEUR pour les comptes qui engagent l'application + * — Service Enfance (qui valide les plannings de toute la collectivité) et DSI + * (qui distribue les accès) : après le jeton, un mot de passe. Les deux secrets + * sont de nature différente, et c'est tout l'intérêt : le jeton est remis par la + * DSI et peut circuler par courriel, le mot de passe est choisi par le titulaire + * à sa première connexion et n'est connu que de lui. La DSI ne peut que + * l'effacer, jamais le lire ni le remplacer. + * + * La connexion se fait donc en deux étapes, matérialisées par $etape : + * « jeton » — saisie du jeton (ou arrivée par role.php?token=…) ; + * « definition » — premier accès d'un compte privilégié : choisir son mot de passe ; + * « verification » — accès suivants : le saisir ; + * « changement » — session ouverte, le titulaire remplace son mot de passe. + * + * Entre les deux étapes, RIEN N'EST OUVERT : la session ne porte qu'une attente + * horodatée (ATTENTE_KEY), qui périme au bout de cinq minutes. Un jeton validé + * sans mot de passe ne donne accès à aucune page. + * + * Comme avant, ce contrôleur n'est pas appelé par runPage() : il s'exécute AVANT + * tout contrôle d'accès, puisqu'il est ce qui permet d'en avoir un. */ final class RoleController extends Controller { + /** Fenêtre et plafond du frein anti-force-brute, par adresse IP. */ + private const FENETRE_MINUTES = 15; + private const ECHECS_MAX = 10; + + /** Attente entre les deux étapes : le jeton est reconnu, le mot de passe manque. */ + private const ATTENTE_KEY = 'pta_auth_attente'; + private const ATTENTE_SECONDES = 300; + + private CompteModel $comptes; + public function __construct(private PDO $pdo) { + $this->comptes = new CompteModel($pdo); } /** - * Affiche le sélecteur et traite sa soumission. - * - * Trois chemins dans une seule méthode, faute de routeur : - * - GET ?action=reset : abandon du rôle courant, puis redirection ; - * - POST : tentative de sélection, puis redirection vers - * l'accueil du rôle ou réaffichage avec $error ; - * - GET simple : affichage du formulaire. + * Affiche l'écran de connexion et traite ses chemins, faute de routeur : + * - GET ?action=reset : déconnexion, puis retour à cet écran ; + * - GET ?action=mot_de_passe : changement de mot de passe, session ouverte ; + * - GET ?token=… : première étape par lien direct ; + * - POST etape=mot_de_passe : seconde étape (définition ou vérification) ; + * - POST : première étape par le champ de saisie. * * Le CSRF est vérifié à la main plutôt que par Csrf::assertPayload() : les - * données arrivent ici en formulaire classique ($_POST / $_GET) et non en - * JSON, et un échec doit produire une page HTML, pas une réponse JSON. - * Un jeton invalide sur le reset est ignoré EN SILENCE : l'utilisateur est - * simplement renvoyé au sélecteur, sans message d'échec inutile. + * données arrivent en formulaire classique et un échec doit produire une page + * HTML, pas une réponse JSON. */ public function index(): void { $access = new Access($this->pdo); - // Le changement de rôle modifie l'état de la session : il est protégé - // par le jeton CSRF au même titre que les écritures de l'API. if (($_GET['action'] ?? '') === 'reset') { - $provided = (string) ($_GET['csrf_token'] ?? ''); - if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) { - $access->clear(); - } - header('Location: role.php', true, 302); - exit; + $this->deconnecter($access); } $error = ''; - if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { - $expected = Csrf::ensureToken(); - $provided = (string) ($_POST['csrf_token'] ?? ''); - if ($provided === '' || !hash_equals($expected, $provided)) { + $notice = ''; + $etape = 'jeton'; + $methode = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + + // Changement volontaire du mot de passe : le seul chemin qui exige une + // session DÉJÀ ouverte. + if (($_GET['action'] ?? '') === 'mot_de_passe' || ($_POST['etape'] ?? '') === 'changement') { + [$etape, $error, $notice] = $this->changerMotDePasse($access, $methode); + } elseif ($methode === 'POST') { + if (!$this->csrfValide()) { $error = 'La session a expiré. Rechargez la page et recommencez.'; + $etape = $this->attente() !== null ? $this->etapeAttendue() : 'jeton'; + } elseif (($_POST['etape'] ?? '') === 'mot_de_passe') { + [$etape, $error] = $this->traiterMotDePasse($access); } else { - try { - $role = strtoupper(trim((string) ($_POST['role'] ?? ''))); - $agentId = filter_var($_POST['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null; - $structureId = filter_var($_POST['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null; - $access->select($role, $agentId ? (int) $agentId : null, $structureId ? (int) $structureId : null); - header('Location: ' . $access->homeUrl(), true, 302); - exit; - } catch (\DomainException $e) { - $error = $e->getMessage(); - } + [$etape, $error] = $this->connecter($access, (string) ($_POST['token'] ?? ''), 'FORMULAIRE'); } + } elseif (isset($_GET['token'])) { + [$etape, $error] = $this->connecter($access, (string) $_GET['token'], 'LIEN'); + } elseif ($this->attente() !== null) { + // Rechargement de la page au milieu de la connexion : on reste sur la + // seconde étape plutôt que de renvoyer l'utilisateur à son jeton. + $etape = $this->etapeAttendue(); } + $attente = $this->attente(); + Security::sendHtmlHeaders(); $this->render('pages/role', [ - 'pageTitle' => 'Choisir votre accès', + 'pageTitle' => 'Connexion', 'cspNonce' => Security::nonce(), 'csrfToken' => Csrf::ensureToken(), - 'agents' => (new AgentModel($this->pdo))->allActive(), - 'structures' => (new StructureModel($this->pdo))->allActive(), 'error' => $error, + 'notice' => $notice, + 'etape' => $etape, + 'titulaire' => (string) ($attente['libelle'] ?? ''), + 'longueurMinimale' => CompteModel::MOT_DE_PASSE_MINIMUM, 'currentProfile' => $access->isConfigured() ? $access->profile() : null, + 'homeUrl' => $access->isConfigured() ? $access->homeUrl() : 'role.php', ]); } + + /** + * Première étape : vérifie le jeton. + * + * Ne revient QUE si la connexion n'est pas allée à son terme — une connexion + * complète redirige et termine le script. + * + * @param string $origine « FORMULAIRE » ou « LIEN », conservé au journal : + * c'est ce qui permet à la DSI de voir si les liens + * directs circulent. + * + * @return array{0: string, 1: string} [étape à afficher, message d'erreur] + */ + private function connecter(Access $access, string $jetonBrut, string $origine): array + { + $jeton = CompteModel::normaliser($jetonBrut); + if ($jeton === '') { + return ['jeton', 'Saisissez votre jeton d’accès.']; + } + + $blocage = $this->blocageEnCours(); + if ($blocage !== '') { + return ['jeton', $blocage]; + } + + $compte = $this->comptes->authentifier($jeton); + if ($compte === null) { + // Ni le jeton présenté ni aucun fragment ne sont journalisés : une + // trace du secret dans le journal en ferait un secret de moins. + Journal::connexion('JETON_REFUSE', [ + 'resultat' => 'REFUS', + 'description' => 'Jeton d’accès inconnu, révoqué ou porté par une identité inactive.', + 'contexte' => ['origine' => $origine], + ]); + + return ['jeton', 'Ce jeton d’accès est inconnu ou n’est plus valide. Rapprochez-vous de la DSI.']; + } + + // Agents et responsables : le jeton suffit, la session s'ouvre ici. + if (!CompteModel::exigeMotDePasse($compte)) { + $this->ouvrirSession($access, $compte, ['jeton'], $origine); + } + + // Comptes privilégiés : on retient l'attente et on demande le second + // facteur. Rien n'est ouvert tant qu'il n'est pas fourni. + $this->poserAttente($compte, $origine); + + Journal::connexion('JETON_ACCEPTE_ATTENTE_MDP', [ + 'id_compte' => (int) $compte['id_compte'], + 'role' => (string) $compte['role'], + 'acteur' => (string) $compte['identite_libelle'], + 'description' => 'Jeton accepté ; mot de passe attendu pour ouvrir la session.', + 'contexte' => ['origine' => $origine], + ]); + + return [$this->etapeAttendue(), '']; + } + + /** + * Seconde étape : définition ou vérification du mot de passe. + * + * Le compte est RELU EN BASE plutôt que repris de la session : entre les deux + * étapes, la DSI a pu le désactiver, ou l'agent qui le porte être fermé. + * + * @return array{0: string, 1: string} [étape à afficher, message d'erreur] + */ + private function traiterMotDePasse(Access $access): array + { + $attente = $this->attente(); + if ($attente === null) { + return ['jeton', 'La connexion a expiré. Saisissez de nouveau votre jeton d’accès.']; + } + + $compte = $this->comptes->trouverPourAuthentification((int) $attente['compte_id']); + if ($compte === null || !CompteModel::exigeMotDePasse($compte)) { + $this->oublierAttente(); + + return ['jeton', 'Ce compte n’est plus accessible. Rapprochez-vous de la DSI.']; + } + + $motDePasse = (string) ($_POST['mot_de_passe'] ?? ''); + + // Premier accès : le titulaire choisit son mot de passe. Personne ne le + // lui a transmis, et personne ne pourra le lire ensuite. + if (!CompteModel::motDePasseDefini($compte)) { + try { + $this->comptes->definirMotDePasse( + (int) $compte['id_compte'], + $motDePasse, + (string) ($_POST['mot_de_passe_confirmation'] ?? '') + ); + } catch (DomainException $e) { + return ['definition', $e->getMessage()]; + } + + $this->ouvrirSession($access, $compte, ['jeton', 'mot_de_passe'], (string) $attente['origine'], true); + } + + $blocage = $this->blocageEnCours(); + if ($blocage !== '') { + return ['verification', $blocage]; + } + + if (!$this->comptes->verifierMotDePasse($compte, $motDePasse)) { + Journal::connexion('MOT_DE_PASSE_REFUSE', [ + 'resultat' => 'REFUS', + 'id_compte' => (int) $compte['id_compte'], + 'role' => (string) $compte['role'], + 'acteur' => (string) $compte['identite_libelle'], + 'description' => 'Mot de passe incorrect après un jeton valide.', + ]); + + return ['verification', 'Mot de passe incorrect.']; + } + + $this->ouvrirSession($access, $compte, ['jeton', 'mot_de_passe'], (string) $attente['origine']); + + return ['jeton', '']; // Inatteignable : ouvrirSession() redirige. + } + + /** + * Changement de mot de passe par son titulaire, session ouverte. + * + * L'ancien mot de passe est exigé : sans lui, un poste laissé déverrouillé + * suffirait à confisquer un compte. + * + * @return array{0: string, 1: string, 2: string} [étape, erreur, message de succès] + */ + private function changerMotDePasse(Access $access, string $methode): array + { + if (!$access->isConfigured()) { + return ['jeton', 'Connectez-vous avant de changer votre mot de passe.', '']; + } + + $compte = $this->comptes->trouverPourAuthentification((int) $access->accountId()); + if ($compte === null || !CompteModel::exigeMotDePasse($compte)) { + return ['jeton', 'Votre type de compte n’utilise pas de mot de passe.', '']; + } + + if ($methode !== 'POST') { + return ['changement', '', '']; + } + if (!$this->csrfValide()) { + return ['changement', 'La session a expiré. Rechargez la page et recommencez.', '']; + } + + if (!$this->comptes->verifierMotDePasse($compte, (string) ($_POST['mot_de_passe_actuel'] ?? ''))) { + Journal::refus('MOT_DE_PASSE_REFUSE', 'Mot de passe actuel incorrect lors d’un changement.'); + + return ['changement', 'Le mot de passe actuel est incorrect.', '']; + } + + try { + $this->comptes->definirMotDePasse( + (int) $compte['id_compte'], + (string) ($_POST['mot_de_passe'] ?? ''), + (string) ($_POST['mot_de_passe_confirmation'] ?? '') + ); + } catch (DomainException $e) { + return ['changement', $e->getMessage(), '']; + } + + Journal::ecriture( + 'MOT_DE_PASSE_MODIFIE', + 'compte_acces', + (int) $compte['id_compte'], + 'Mot de passe changé par son titulaire.' + ); + + return ['changement', '', 'Votre mot de passe a été modifié.']; + } + + /** + * Ouvre la session et redirige : cette méthode NE REVIENT JAMAIS. + * + * @param array $compte Ligne de v_acces_compte. + * @param array $facteurs Facteurs réellement présentés. + * @param bool $premier Vrai si le mot de passe vient d'être défini. + */ + private function ouvrirSession(Access $access, array $compte, array $facteurs, string $origine, bool $premier = false): never + { + $this->oublierAttente(); + $access->openSession($compte); + $this->comptes->enregistrerConnexion((int) $compte['id_compte']); + + if ($premier) { + // Journalisé APRÈS l'ouverture de session, pour que la trace porte le + // nom du titulaire plutôt que d'être anonyme. + Journal::ecriture( + 'MOT_DE_PASSE_DEFINI', + 'compte_acces', + (int) $compte['id_compte'], + 'Mot de passe défini par son titulaire à sa première connexion.' + ); + } + + Journal::connexion('CONNEXION_REUSSIE', [ + 'description' => sprintf( + '%s s’est connecté (%s).', + (string) ($compte['identite_libelle'] ?? $compte['libelle']), + (string) $compte['role'] + ), + 'contexte' => ['origine' => $origine, 'facteurs' => $facteurs], + ]); + + // La redirection efface le jeton de la barre d'adresse quand il est + // arrivé par un lien direct. + header('Location: ' . $access->homeUrl(), true, 302); + exit; + } + + /** Déconnexion : trace d'abord, vide la session ensuite, redirige enfin. */ + private function deconnecter(Access $access): never + { + $provided = (string) ($_GET['csrf_token'] ?? ''); + if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) { + if ($access->isConfigured()) { + // L'ordre compte : après clear(), la session ne dit plus qui + // vient de partir et la trace serait anonyme. + Journal::connexion('DECONNEXION', ['description' => 'Fin de session à la demande de l’utilisateur.']); + } + $this->oublierAttente(); + $access->clear(); + } + + header('Location: role.php', true, 302); + exit; + } + + /** + * Frein anti-force-brute, commun aux deux étapes : au-delà du plafond, plus + * aucun secret n'est même comparé. + * + * @return string Message à afficher, vide si la voie est libre. + */ + private function blocageEnCours(): string + { + $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + $echecs = (new JournalModel($this->pdo))->echecsRecents($ip, self::FENETRE_MINUTES); + if ($echecs < self::ECHECS_MAX) { + return ''; + } + + // Le blocage est tracé pour que la DSI voie une attaque en cours plutôt + // que d'avoir à la déduire. + Journal::connexion('CONNEXION_BLOQUEE', [ + 'resultat' => 'REFUS', + 'description' => sprintf( + 'Trop de secrets refusés depuis %s : connexion bloquée.', + $ip !== '' ? $ip : 'une adresse inconnue' + ), + 'contexte' => ['echecs' => $echecs, 'fenetre_minutes' => self::FENETRE_MINUTES], + ]); + + return sprintf( + 'Trop de tentatives infructueuses. Réessayez dans %d minutes ou contactez la DSI.', + self::FENETRE_MINUTES + ); + } + + /** + * Mémorise le compte reconnu par son jeton, en attendant son mot de passe. + * + * @param array $compte + */ + private function poserAttente(array $compte, string $origine): void + { + Session::start(); + // Regénération de l'identifiant AVANT de poser l'attente : un identifiant + // de session imposé par un tiers ne doit pas porter cette étape. + session_regenerate_id(true); + $_SESSION[self::ATTENTE_KEY] = [ + 'compte_id' => (int) $compte['id_compte'], + 'libelle' => (string) $compte['identite_libelle'], + 'definition' => !CompteModel::motDePasseDefini($compte), + 'origine' => $origine, + 'expire' => time() + self::ATTENTE_SECONDES, + ]; + } + + /** + * Attente en cours, ou null si elle est absente ou périmée. + * + * @return array|null + */ + private function attente(): ?array + { + Session::start(); + $attente = $_SESSION[self::ATTENTE_KEY] ?? null; + if (!is_array($attente) || (int) ($attente['expire'] ?? 0) < time()) { + $this->oublierAttente(); + + return null; + } + + return $attente; + } + + private function oublierAttente(): void + { + Session::start(); + unset($_SESSION[self::ATTENTE_KEY]); + } + + /** « definition » au premier accès, « verification » ensuite. */ + private function etapeAttendue(): string + { + return ($this->attente()['definition'] ?? false) ? 'definition' : 'verification'; + } + + /** Jeton CSRF du formulaire, vérifié à la main (données non JSON). */ + private function csrfValide(): bool + { + $provided = (string) ($_POST['csrf_token'] ?? ''); + + return $provided !== '' && hash_equals(Csrf::ensureToken(), $provided); + } } diff --git a/app/Controllers/StructureController.php b/app/Controllers/StructureController.php index b870e52..c4dc154 100644 --- a/app/Controllers/StructureController.php +++ b/app/Controllers/StructureController.php @@ -6,6 +6,7 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Csrf; +use App\Core\Journal; use App\Core\JsonResponse; use App\Core\PdfResponse; use App\Core\Request; @@ -215,6 +216,14 @@ final class StructureController ]); } + Journal::ecriture( + 'STRUCTURE_CREEE', + 'structure', + isset($structure['id_structure']) ? (int) $structure['id_structure'] : null, + sprintf('Création du lieu d’affectation « %s » (%s).', $name, $code), + ['code' => $code, 'type_affectation' => $typeAffectation] + ); + JsonResponse::send(201, [ 'message' => sprintf('Le lieu d’affectation « %s » a été ajouté.', $name), 'structure' => $structure, diff --git a/app/Controllers/TeamController.php b/app/Controllers/TeamController.php index ce85f9f..6227156 100644 --- a/app/Controllers/TeamController.php +++ b/app/Controllers/TeamController.php @@ -6,6 +6,7 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Csrf; +use App\Core\Journal; use App\Core\JsonResponse; use App\Core\Request; use App\Models\AgentModel; @@ -114,6 +115,13 @@ final class TeamController 'pourcentage_diplomes_min' => $qualified, 'commentaire' => trim((string) ($payload['commentaire'] ?? '')) ?: null, ]); + Journal::ecriture( + 'EQUIPE_CREEE', + 'equipe_pta', + (int) $id, + sprintf('Création de l’équipe « %s » sur le lieu #%d.', $label, (int) $structureId), + ['id_structure' => (int) $structureId, 'type_equipe' => $type] + ); JsonResponse::send(201, ['message' => 'L’équipe a été créée.', 'id_equipe' => $id]); } catch (Throwable $e) { JsonResponse::send(500, ['error' => 'Impossible de créer l’équipe.', 'details' => $e->getMessage()]); @@ -165,6 +173,13 @@ final class TeamController $function, trim((string) ($payload['commentaire'] ?? '')) ?: null ); + Journal::ecriture( + 'EQUIPE_MEMBRE_AJOUTE', + 'equipe_pta_membre', + (int) $teamId, + sprintf('Agent #%d ajouté à l’équipe #%d (%s).', (int) $agentId, (int) $teamId, $function), + ['id_agent' => (int) $agentId, 'fonction_equipe' => $function] + ); JsonResponse::send(200, ['message' => 'L’agent a été ajouté à l’équipe.']); } catch (Throwable $e) { JsonResponse::send(422, ['error' => $e->getMessage()]); @@ -192,6 +207,13 @@ final class TeamController JsonResponse::send(422, ['error' => 'Équipe ou statut invalide.']); } $this->teamModel->setStatus((int) $teamId, $status); + Journal::ecriture( + 'EQUIPE_STATUT_MODIFIE', + 'equipe_pta', + (int) $teamId, + sprintf('Équipe #%d passée au statut %s.', (int) $teamId, $status), + ['statut' => $status] + ); JsonResponse::send(200, ['message' => 'Le statut de l’équipe a été mis à jour.']); } } diff --git a/app/Controllers/VacationController.php b/app/Controllers/VacationController.php index 7ec3293..24c95d3 100644 --- a/app/Controllers/VacationController.php +++ b/app/Controllers/VacationController.php @@ -6,6 +6,7 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Csrf; +use App\Core\Journal; use App\Core\JsonResponse; use App\Core\Request; use App\Models\VacationModel; @@ -177,6 +178,13 @@ final class VacationController } $count = $this->vacationModel->saveMany($periods); + Journal::ecriture( + 'VACANCES_ENREGISTREES', + 'periode_vacance', + null, + sprintf('%d période(s) de vacances enregistrée(s) ou mise(s) à jour.', $count), + ['nombre' => $count] + ); JsonResponse::send(200, [ 'success' => true, 'count' => $count, @@ -208,6 +216,13 @@ final class VacationController JsonResponse::send(404, ['error' => 'Période introuvable.']); } + Journal::ecriture( + 'VACANCES_SUPPRIMEES', + 'periode_vacance', + (int) $id, + sprintf('Période de vacances #%d supprimée.', (int) $id) + ); + JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']); } diff --git a/app/Controllers/WeekTemplateController.php b/app/Controllers/WeekTemplateController.php index 08a99e9..3f124df 100644 --- a/app/Controllers/WeekTemplateController.php +++ b/app/Controllers/WeekTemplateController.php @@ -6,6 +6,7 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Csrf; +use App\Core\Journal; use App\Core\JsonResponse; use App\Core\Request; use App\Core\Security; @@ -200,6 +201,14 @@ final class WeekTemplateController ]); } + Journal::ecriture( + 'SEMAINE_TYPE_ENREGISTREE', + 'modele_semaine_agent', + (int) $template['id_modele'], + sprintf('Semaine type « %s » enregistrée depuis l’éditeur pour l’agent #%d.', (string) $template['nom'], (int) $agentId), + ['id_agent' => (int) $agentId, 'nombre_creneaux' => (int) $template['nombre_creneaux'], 'remplacement' => $replace] + ); + JsonResponse::send(200, [ 'success' => true, 'template' => [ @@ -259,6 +268,14 @@ final class WeekTemplateController ]); } + Journal::ecriture( + 'SEMAINE_TYPE_ENREGISTREE', + 'modele_semaine_agent', + isset($template['id_modele']) ? (int) $template['id_modele'] : null, + sprintf('Semaine type « %s » enregistrée depuis une semaine planifiée pour l’agent #%d.', $name, (int) $agentId), + ['id_agent' => (int) $agentId, 'nombre_creneaux' => (int) $template['nombre_creneaux'], 'remplacement' => $replace] + ); + JsonResponse::send(200, [ 'success' => true, 'template' => $template, @@ -290,12 +307,18 @@ final class WeekTemplateController if (!$templateId || !$agentId || $week === null || !in_array($mode, ['merge', 'replace'], true)) { JsonResponse::send(422, ['error' => 'Semaine type, agent, semaine ou mode d’import invalide.']); } - if ($this->agentModel->findActive((int) $agentId) === null) { + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); } + [$year, $weekNumber] = $week; + $lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1); + $this->assertDatesWithinContract($agent, array_map( + static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'), + range(0, 4) + )); try { - [$year, $weekNumber] = $week; $result = $this->templateModel->applyToWeek((int) $templateId, (int) $agentId, $year, $weekNumber, $mode); } catch (DomainException $e) { JsonResponse::send(409, ['error' => $e->getMessage()]); @@ -318,6 +341,27 @@ final class WeekTemplateController $message .= ' Les plannings validés modifiés ont été repassés en brouillon.'; } + Journal::ecriture( + 'SEMAINE_TYPE_APPLIQUEE', + 'planning', + null, + sprintf( + 'Semaine type « %s » appliquée à l’agent #%d sur la semaine %d de %d : %d créneau(x).', + (string) $result['template']['nom'], + (int) $agentId, + $weekNumber, + $year, + (int) $result['inserted'] + ), + [ + 'id_agent' => (int) $agentId, + 'id_modele' => (int) $templateId, + 'annee' => $year, + 'semaine' => $weekNumber, + 'mode' => $mode, + ] + ); + JsonResponse::send(200, [ 'success' => true, 'message' => $message, @@ -368,9 +412,11 @@ final class WeekTemplateController ) { JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']); } - if ($this->agentModel->findActive((int) $agentId) === null) { + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); } + $this->assertPeriodWithinContract($agent, $startDate, $endDate); try { $preview = $this->templateModel->previewPeriod( @@ -435,9 +481,11 @@ final class WeekTemplateController ) { JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']); } - if ($this->agentModel->findActive((int) $agentId) === null) { + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); } + $this->assertPeriodWithinContract($agent, $startDate, $endDate); try { $result = $this->templateModel->applyToPeriod( @@ -499,6 +547,27 @@ final class WeekTemplateController $quotas[(string) $quota['date_debut_periode']] = $quota; } + Journal::ecriture( + 'SEMAINE_TYPE_APPLIQUEE_PERIODE', + 'planning', + null, + sprintf( + 'Semaine type #%d appliquée à l’agent #%d du %s au %s.', + (int) $templateId, + (int) $agentId, + $startDate, + $endDate + ), + [ + 'id_agent' => (int) $agentId, + 'id_modele' => (int) $templateId, + 'date_debut' => $startDate, + 'date_fin' => $endDate, + 'mode' => $mode, + 'filtre_periode' => $periodFilter, + ] + ); + JsonResponse::send(200, [ 'success' => true, 'message' => $message, @@ -533,6 +602,14 @@ final class WeekTemplateController JsonResponse::send(404, ['error' => 'Semaine type introuvable.']); } + Journal::ecriture( + 'SEMAINE_TYPE_SUPPRIMEE', + 'modele_semaine_agent', + (int) $templateId, + sprintf('Semaine type #%d supprimée pour l’agent #%d.', (int) $templateId, (int) $agentId), + ['id_agent' => (int) $agentId] + ); + JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']); } @@ -541,6 +618,51 @@ final class WeekTemplateController * * @return array{0: int, 1: int}|null [année ISO, semaine], ou null. */ + /** + * Refuse toute affectation hors des dates de contrat de l'agent (V7.15). + * + * Copie assumée de PlanningController::assertDatesWithinContract() : les deux + * contrôleurs n'ont pas de base commune, et la règle doit s'appliquer des + * deux côtés. Le MESSAGE, lui, est construit une seule fois par AgentModel — + * c'est lui qui doit rester unique, pas la garde. + * + * @param array $agent + * @param array $dates + */ + private function assertDatesWithinContract(array $agent, array $dates): void + { + $hors = AgentModel::datesOutsideContract($agent, $dates); + if ($hors === []) { + return; + } + + JsonResponse::send(422, [ + 'error' => AgentModel::contractViolationMessage($agent, $hors), + 'code' => 'HORS_CONTRAT', + 'dates' => $hors, + ]); + } + + /** + * Même règle, pour une PÉRIODE entière. + * + * Seules les bornes sont contrôlées, et c'est suffisant : le contrat est un + * intervalle continu, donc une période dont les deux extrémités y tombent y + * est entièrement contenue. Contrôler chacun des jours d'une duplication + * annuelle coûterait 365 comparaisons pour la même réponse. + * + * Le refus porte volontairement sur la période DEMANDÉE et non sur les seuls + * jours réellement écrits : personne ne doit avoir à deviner qu'une + * duplication « du 1er janvier au 31 décembre » n'a rien produit en novembre + * parce que le contrat s'arrêtait en octobre. + * + * @param array $agent + */ + private function assertPeriodWithinContract(array $agent, string $startDate, string $endDate): void + { + $this->assertDatesWithinContract($agent, [$startDate, $endDate]); + } + private function parseWeek(string $value): ?array { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { diff --git a/app/Core/Access.php b/app/Core/Access.php index 9fb56bd..113572e 100644 --- a/app/Core/Access.php +++ b/app/Core/Access.php @@ -22,9 +22,17 @@ final class Access public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE'; /** Périmètre complet ; seul rôle habilité à valider un planning. */ public const ROLE_SERVICE = 'SERVICE_ENFANCE'; + /** + * Administration technique (V7.11) : comptes d'accès, jetons et journal. + * Aucun droit métier — la DSI ne voit ni les plannings, ni les agents. + */ + public const ROLE_DSI = 'DSI'; private const SESSION_KEY = 'pta_access'; + /** @var array Les quatre rôles connus de l'application. */ + private const ROLES = [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE, self::ROLE_DSI]; + /** * Caches par requête : les vues d'accès sont interrogées une seule fois par * rôle et par requête HTTP. Rien n'est mis en cache entre deux requêtes, pour @@ -37,6 +45,19 @@ final class Access /** @var array> */ private array $pageCache = []; + /** + * Compte d'accès de la session, relu UNE FOIS par requête. + * + * Il est relu à chaque requête et non conservé en session : c'est ce qui + * fait qu'un jeton désactivé par la DSI coupe l'accès à la requête suivante, + * sans attendre l'expiration de la session. + * + * @var array|null + */ + private ?array $accountCache = null; + + private bool $accountLoaded = false; + public function __construct(private PDO $pdo) { Session::start(); @@ -50,22 +71,32 @@ final class Access public function isConfigured(): bool { $role = $this->role(); + if ($role === null) { + return false; + } + + // Depuis la V7.11, toute session est adossée à un compte d'accès actif : + // pas de compte vivant, pas de session. Un jeton révoqué, un agent + // désactivé ou un lieu fermé referment donc l'accès immédiatement. + $compte = $this->accountRecord(); + if ($compte === null) { + return false; + } + if ($role === self::ROLE_AGENT) { return $this->agentId() !== null && $this->agentRecord() !== null; } if ($role === self::ROLE_RESPONSABLE) { return $this->structureId() !== null && $this->structureRecord() !== null; } - return $role === self::ROLE_SERVICE; + return true; } /** Rôle en session, ou null si aucun rôle valide n'est sélectionné. */ public function role(): ?string { $role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? ''))); - return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true) - ? $role - : null; + return in_array($role, self::ROLES, true) ? $role : null; } /** Agent sélectionné (rôle AGENT uniquement), ou null. */ @@ -100,50 +131,87 @@ final class Access } /** - * Enregistre la sélection de rôle en session. + * Ouvre la session de travail à partir d'un compte d'accès authentifié. * - * ATTENTION : ceci est un sélecteur, PAS une authentification — l'utilisateur - * choisit librement son identité. C'est la faille ouverte la plus importante - * du projet ; un passage en production doit remplacer cet appel par une - * authentification réelle, sans toucher au reste de la mécanique de droits. + * DEPUIS LA V7.11, C'EST LE SEUL CHEMIN D'ENTRÉE : l'ancien sélecteur libre + * (select()) a disparu, une session ne peut plus naître que de la + * présentation d'un jeton valide. Le rôle, l'agent et le lieu ne sont plus + * choisis par l'utilisateur mais LUS SUR LE COMPTE — c'est ce qui rend la + * mécanique de droits opposable. * - * @throws \DomainException Si le rôle est inconnu ou l'identité inactive. + * La vérification du jeton elle-même appartient à CompteModel : cette + * méthode reçoit un compte déjà authentifié. + * + * @param array $compte Ligne de v_acces_compte. */ - public function select(string $role, ?int $agentId = null, ?int $structureId = null): void + public function openSession(array $compte): void { - $role = strtoupper(trim($role)); - if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) { - throw new \DomainException('Rôle invalide.'); - } - - $selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null]; - - if ($role === self::ROLE_AGENT) { - if (!$agentId || !$this->activeAgentExists($agentId)) { - throw new \DomainException('Sélectionnez un agent actif.'); - } - $selection['agent_id'] = $agentId; - } elseif ($role === self::ROLE_RESPONSABLE) { - if (!$structureId || !$this->activeStructureExists($structureId)) { - throw new \DomainException('Sélectionnez un lieu d’affectation actif.'); - } - $selection['structure_id'] = $structureId; - } - - // Regénération de l'identifiant de session à chaque changement de rôle : - // parade classique à la fixation de session. + // Regénération de l'identifiant de session à chaque ouverture : parade + // classique à la fixation de session. session_regenerate_id(true); - $_SESSION[self::SESSION_KEY] = $selection; - // Le jeton CSRF suit le cycle de vie de la session : un changement de - // rôle invalide le jeton précédent. + + $_SESSION[self::SESSION_KEY] = [ + 'role' => (string) $compte['role'], + 'agent_id' => isset($compte['id_agent']) && $compte['id_agent'] !== null + ? (int) $compte['id_agent'] + : null, + 'structure_id' => isset($compte['id_structure']) && $compte['id_structure'] !== null + ? (int) $compte['id_structure'] + : null, + 'compte_id' => (int) $compte['id_compte'], + // Libellé figé pour le journal : il évite une jointure à chaque trace. + 'acteur' => (string) ($compte['identite_libelle'] ?? $compte['libelle'] ?? ''), + ]; + + // Le jeton CSRF suit le cycle de vie de la session : une nouvelle + // connexion invalide le jeton précédent. unset($_SESSION['csrf_token']); + $this->accountCache = null; + $this->accountLoaded = false; } - /** Abandonne la sélection courante (« changer de rôle »). */ + /** Ferme la session de travail (déconnexion). */ public function clear(): void { unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']); session_regenerate_id(true); + $this->accountCache = null; + $this->accountLoaded = false; + } + + /** Identifiant du compte d'accès de la session, ou null. */ + public function accountId(): ?int + { + $value = filter_var($_SESSION[self::SESSION_KEY]['compte_id'] ?? null, FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 1], + ]); + return $value === false ? null : (int) $value; + } + + /** + * Instantané de session destiné au JOURNAL, lisible sans instancier Access + * ni ouvrir de connexion. + * + * Il expose la clé de session privée sous une forme stable : Journal (et lui + * seul) a besoin de connaître l'acteur courant depuis un contexte statique, + * sans pouvoir dépendre d'une instance. + * + * @return array{id_compte: int|null, role: string|null, acteur: string|null} + */ + public static function sessionSnapshot(): array + { + $selection = $_SESSION[self::SESSION_KEY] ?? []; + $compteId = filter_var($selection['compte_id'] ?? null, FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 1], + ]); + $role = strtoupper(trim((string) ($selection['role'] ?? ''))); + $acteur = trim((string) ($selection['acteur'] ?? '')); + + return [ + 'id_compte' => $compteId === false ? null : (int) $compteId, + 'role' => in_array($role, self::ROLES, true) ? $role : null, + 'acteur' => $acteur === '' ? null : $acteur, + ]; } /** @@ -164,6 +232,7 @@ final class Access 'label' => $this->roleLabel(), 'agent_id' => $this->agentId(), 'structure_id' => $this->structureId(), + 'account_id' => $this->accountId(), 'permissions' => $permissions, ]; @@ -182,6 +251,14 @@ final class Access $profile['structure_name'] = (string) $structure['nom']; $profile['structure_code'] = (string) $structure['code']; } + } else { + // Service Enfance et DSI ne portent aucune identité métier : le + // libellé affiché est celui du compte (« Direction des systèmes + // d'information », « Service Enfance — Camille »…). + $compte = $this->accountRecord(); + if ($compte !== null) { + $profile['label'] = (string) $compte['identite_libelle']; + } } return $profile; @@ -269,6 +346,9 @@ final class Access self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(), self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(), self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php', + // La DSI n'a qu'une page ; le repli sur role.php évite la boucle de + // redirection si la migration V7.11 n'est pas jouée. + self::ROLE_DSI => $this->canAccessPage('dsi') ? 'dsi.php' : 'role.php', default => 'role.php', }; } @@ -286,6 +366,11 @@ final class Access exit; } if (!$this->canAccessPage($page)) { + Journal::refus( + 'PAGE_REFUSEE', + sprintf('Accès refusé à la page « %s ».', $page), + ['contexte' => ['page' => $page]] + ); header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302); exit; } @@ -300,19 +385,58 @@ final class Access public function requireRoles(array $roles): void { if (!$this->isConfigured()) { - JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']); + $this->refuser(401, 'SESSION_ABSENTE', 'Appel d’API sans session valide.', [ + 'endpoint' => $this->endpointCourant(), + ]); + JsonResponse::send(401, ['error' => 'Connectez-vous avec votre jeton d’accès avant de continuer.', 'code' => 'ROLE_REQUIRED']); } if (!in_array($this->role(), $roles, true)) { + $this->refuser(403, 'ROLE_INSUFFISANT', 'Rôle insuffisant pour cette action.', [ + 'endpoint' => $this->endpointCourant(), + 'roles_attendus' => array_values($roles), + ]); JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']); } } + /** + * Journalise un refus d'API. Méthode privée volontairement minuscule : elle + * n'existe que pour que les gardes ci-dessous tiennent en deux lignes. + * + * @param array $contexte + */ + private function refuser(int $status, string $action, string $description, array $contexte = []): void + { + Journal::refus($action, $description, [ + 'contexte' => array_merge(['statut_http' => $status], $contexte), + ]); + } + + /** Chemin de l'endpoint appelé, pour situer un refus dans le journal. */ + private function endpointCourant(): string + { + $chemin = (string) ($_SERVER['SCRIPT_NAME'] ?? ''); + return $chemin !== '' ? basename($chemin) : 'inconnu'; + } + /** Réservé au Service Enfance : validation, référentiels, administration. */ public function requireService(): void { $this->requireRoles([self::ROLE_SERVICE]); } + /** + * Réservé à la DSI : gestion des comptes d'accès et lecture du journal. + * + * Cette garde double le contrôle de page : la page « dsi » n'est autorisée + * qu'au rôle DSI dans acces_role_page, mais un endpoint doit rester sûr même + * si quelqu'un ouvrait ce droit à un autre rôle par une requête SQL. + */ + public function requireDsi(): void + { + $this->requireRoles([self::ROLE_DSI]); + } + /** Rôles autorisés à modifier un brouillon (l'agent est en lecture seule). */ public function requireDraftEditor(): void { @@ -324,6 +448,7 @@ final class Access { $this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]); if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) { + $this->refuser(403, 'PERIMETRE_LIEU', 'Consultation d’un lieu hors périmètre.', ['id_structure' => $structureId]); JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']); } } @@ -333,6 +458,7 @@ final class Access { $this->requireDraftEditor(); if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) { + $this->refuser(403, 'PERIMETRE_LIEU', 'Modification d’un lieu hors périmètre.', ['id_structure' => $structureId]); JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']); } } @@ -354,6 +480,7 @@ final class Access if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) { return; } + $this->refuser(403, 'PERIMETRE_AGENT', 'Consultation d’un agent hors périmètre.', ['id_agent' => $agentId]); JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']); } @@ -412,23 +539,51 @@ final class Access self::ROLE_AGENT => 'Agent', self::ROLE_RESPONSABLE => 'Responsable de structure', self::ROLE_SERVICE => 'Service Enfance', + self::ROLE_DSI => 'DSI', default => 'Rôle non sélectionné', }; } - /** Les vues v_acces_identite_* ne listent que les identités ACTIVES. */ - private function activeAgentExists(int $agentId): bool + /** + * Compte d'accès de la session, relu une fois par requête. + * + * La vue ne renvoie le compte que s'il est actif ET que l'identité qu'il + * porte l'est aussi (`identite_active`) : désactiver un agent suffit donc à + * lui couper l'accès, sans avoir à toucher à son jeton. + * + * @return array|null + */ + private function accountRecord(): ?array { - $stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1'); - $stmt->execute(['id' => $agentId]); - return $stmt->fetchColumn() !== false; - } + if ($this->accountLoaded) { + return $this->accountCache; + } - private function activeStructureExists(int $structureId): bool - { - $stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1'); - $stmt->execute(['id' => $structureId]); - return $stmt->fetchColumn() !== false; + $this->accountLoaded = true; + $this->accountCache = null; + + $compteId = $this->accountId(); + if ($compteId === null) { + return null; + } + + $stmt = $this->pdo->prepare( + 'SELECT id_compte, role, libelle, id_agent, id_structure, identite_libelle + FROM v_acces_compte + WHERE id_compte = :id AND actif = 1 AND identite_active = 1 + LIMIT 1' + ); + $stmt->execute(['id' => $compteId]); + $compte = $stmt->fetch(); + + // Le rôle de la session doit toujours correspondre à celui du compte : + // une session forgée ou survivant à un changement de rôle du compte est + // refusée plutôt que réparée. + if ($compte !== false && (string) $compte['role'] === $this->role()) { + $this->accountCache = $compte; + } + + return $this->accountCache; } /** diff --git a/app/Core/Csrf.php b/app/Core/Csrf.php index 332af85..6c51a38 100644 --- a/app/Core/Csrf.php +++ b/app/Core/Csrf.php @@ -44,6 +44,12 @@ final class Csrf $provided = (string) ($payload['csrf_token'] ?? ''); if ($provided === '' || !hash_equals($expected, $provided)) { + // Un jeton CSRF absent ou faux est le plus souvent une session + // expirée, parfois une tentative réelle : dans les deux cas la trace + // a sa place dans le journal (V7.11). + Journal::refus('CSRF_INVALIDE', 'Jeton de sécurité absent ou invalide.', [ + 'contexte' => ['endpoint' => basename((string) ($_SERVER['SCRIPT_NAME'] ?? 'inconnu'))], + ]); // send() termine le script : rien ne s'exécute après ce point. JsonResponse::send(403, ['error' => 'Jeton de sécurité invalide. Rechargez la page.']); } diff --git a/app/Core/Journal.php b/app/Core/Journal.php new file mode 100644 index 0000000..5105b7c --- /dev/null +++ b/app/Core/Journal.php @@ -0,0 +1,182 @@ + $options Voir enregistrer(). + */ + public static function connexion(string $action, array $options = []): void + { + self::enregistrer(self::CONNEXION, $action, $options); + } + + /** + * Trace une modification en base. + * + * @param string $action Code stable, ex. « PLANNING_VALIDE ». + * @param string $cibleTable Table principale touchée. + * @param int|null $cibleId Identifiant de la ligne visée. + * @param string $description Phrase lisible dans la page DSI. + * @param array $contexte Détail structuré (semaine, lieu…). + */ + public static function ecriture( + string $action, + string $cibleTable, + ?int $cibleId, + string $description, + array $contexte = [] + ): void { + self::enregistrer(self::ECRITURE, $action, [ + 'cible_table' => $cibleTable, + 'cible_id' => $cibleId, + 'description' => $description, + 'contexte' => $contexte, + ]); + } + + /** + * Trace un refus d'accès. Le résultat vaut REFUS, jamais SUCCES. + * + * @param array $options Voir enregistrer(). + */ + public static function refus(string $action, string $description, array $options = []): void + { + self::enregistrer(self::ACCES_REFUSE, $action, array_merge($options, [ + 'description' => $description, + 'resultat' => 'REFUS', + ])); + } + + /** + * Trace une action d'administration technique (comptes, jetons, journal). + * + * @param array $options Voir enregistrer(). + */ + public static function administration(string $action, array $options = []): void + { + self::enregistrer(self::ADMINISTRATION, $action, $options); + } + + /** + * Écrit une ligne de journal. Ne lève jamais d'exception. + * + * L'acteur est déduit de la session, sauf si l'appelant le fournit : c'est + * indispensable à la connexion, où la trace est écrite AVANT que la session + * porte le compte (ou pour un jeton refusé, qui n'ouvre aucune session). + * + * @param string $categorie Une des constantes de classe. + * @param string $action Code stable de l'action. + * @param array $options resultat, cible_table, cible_id, + * description, contexte, id_compte, + * role, acteur. + */ + public static function enregistrer(string $categorie, string $action, array $options = []): void + { + if (self::$pdo === null) { + return; + } + + try { + $session = Access::sessionSnapshot(); + $contexte = $options['contexte'] ?? []; + + $stmt = self::$pdo->prepare( + 'INSERT INTO journal_activite + (categorie, action, resultat, id_compte, role, acteur, + cible_table, cible_id, description, contexte, + adresse_ip, agent_utilisateur) + VALUES + (:categorie, :action, :resultat, :id_compte, :role, :acteur, + :cible_table, :cible_id, :description, :contexte, + :adresse_ip, :agent_utilisateur)' + ); + + $stmt->execute([ + 'categorie' => $categorie, + 'action' => mb_substr($action, 0, 60), + 'resultat' => $options['resultat'] ?? 'SUCCES', + 'id_compte' => $options['id_compte'] ?? $session['id_compte'], + 'role' => $options['role'] ?? $session['role'], + 'acteur' => self::tronquer($options['acteur'] ?? $session['acteur'], 180), + 'cible_table' => self::tronquer($options['cible_table'] ?? null, 64), + 'cible_id' => $options['cible_id'] ?? null, + 'description' => self::tronquer($options['description'] ?? null, 500), + // JSON_INVALID_UTF8_SUBSTITUTE : une donnée mal encodée ne doit + // pas faire échouer l'encodage, donc la trace tout entière. + 'contexte' => $contexte === [] + ? null + : json_encode($contexte, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE), + // REMOTE_ADDR et non X-Forwarded-For : cet en-tête est fourni par + // le client et falsifiable. Derrière un reverse proxy, c'est au + // proxy de réécrire REMOTE_ADDR. + 'adresse_ip' => self::tronquer($_SERVER['REMOTE_ADDR'] ?? null, 45), + 'agent_utilisateur' => self::tronquer($_SERVER['HTTP_USER_AGENT'] ?? null, 255), + ]); + } catch (Throwable $e) { + // Règle 1 : le journal ne casse jamais l'action journalisée. + error_log(sprintf('[PTA][journal] écriture impossible (%s) : %s', $action, $e->getMessage())); + } + } + + /** Tronque à la longueur de la colonne, en préservant les caractères accentués. */ + private static function tronquer(?string $valeur, int $longueur): ?string + { + if ($valeur === null) { + return null; + } + $valeur = trim($valeur); + return $valeur === '' ? null : mb_substr($valeur, 0, $longueur); + } +} diff --git a/app/Models/AgentModel.php b/app/Models/AgentModel.php index 135af2e..bc7bfc5 100644 --- a/app/Models/AgentModel.php +++ b/app/Models/AgentModel.php @@ -46,6 +46,149 @@ final class AgentModel extends BaseModel return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0; } + /** + * Bornes du contrat, telles qu'elles servent aux contrôles d'affectation. + * + * Les deux bornes sont FACULTATIVES et n'ont pas le même sens quand elles + * manquent : + * - pas de date de début : contrat d'origine inconnue (fiche ancienne), on + * n'oppose alors aucune borne basse plutôt que d'inventer une date ; + * - pas de date de fin : contrat à durée indéterminée, la borne haute + * n'existe pas. + * Dans les deux cas, l'absence ouvre — elle ne ferme jamais. + * + * @param array $agent Fiche agent, telle que renvoyée par + * findActive() ou allActive(). + * + * @return array{0: string|null, 1: string|null} [début, fin] au format AAAA-MM-JJ. + */ + public static function contractRange(array $agent): array + { + $debut = trim((string) ($agent['date_debut_contrat'] ?? '')); + $fin = trim((string) ($agent['date_fin_contrat'] ?? '')); + + return [$debut !== '' ? $debut : null, $fin !== '' ? $fin : null]; + } + + /** + * Vrai si la date tombe dans les bornes du contrat. + * + * Comparaison de chaînes « AAAA-MM-JJ » : ce format se compare + * lexicographiquement dans le même ordre que chronologiquement, ce qui évite + * de construire deux objets DateTime par créneau contrôlé. + */ + public static function contractCoversDate(array $agent, string $date): bool + { + [$debut, $fin] = self::contractRange($agent); + + if ($debut !== null && $date < $debut) { + return false; + } + + return !($fin !== null && $date > $fin); + } + + /** + * Filtre les dates qui tombent hors contrat, dédoublonnées et triées. + * + * @param array $dates + * + * @return array + */ + public static function datesOutsideContract(array $agent, array $dates): array + { + $hors = []; + foreach ($dates as $date) { + $date = trim((string) $date); + if ($date !== '' && !self::contractCoversDate($agent, $date)) { + $hors[$date] = true; + } + } + $hors = array_keys($hors); + sort($hors); + + return $hors; + } + + /** « du 01/09/2026 au 31/08/2027 », « à partir du 01/09/2026 », « jusqu'au … ». */ + public static function contractPeriodLabel(array $agent): string + { + [$debut, $fin] = self::contractRange($agent); + $lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date))); + + if ($debut !== null && $fin !== null) { + return sprintf('du %s au %s', $lisible($debut), $lisible($fin)); + } + if ($debut !== null) { + return sprintf('à partir du %s', $lisible($debut)); + } + if ($fin !== null) { + return sprintf('jusqu’au %s', $lisible($fin)); + } + + return 'sans date de contrat renseignée'; + } + + /** + * Message de refus commun à tous les points d'écriture, pour que + * l'utilisateur lise la même phrase quel que soit l'écran qui l'a produit. + * + * @param array $datesHorsContrat + */ + public static function contractViolationMessage(array $agent, array $datesHorsContrat): string + { + $lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date))); + $exemples = array_map($lisible, array_slice($datesHorsContrat, 0, 3)); + if (count($datesHorsContrat) > 3) { + $exemples[] = sprintf('et %d autre(s)', count($datesHorsContrat) - 3); + } + + return sprintf( + '%s %s n’est pas sous contrat à cette période : son contrat court %s. Date(s) refusée(s) : %s.', + (string) ($agent['prenom'] ?? ''), + (string) ($agent['nom'] ?? ''), + self::contractPeriodLabel($agent), + implode(', ', $exemples) + ); + } + + /** + * Créneaux déjà planifiés en dehors d'une fenêtre de contrat. + * + * Sert au contrôle MIROIR de l'affectation : raccourcir un contrat ne doit + * pas laisser derrière lui des affectations orphelines. Interroge donc les + * créneaux, pas les plannings — un planning peut être à cheval sur la borne. + * + * @return array{nombre: int, premiere: string|null, derniere: string|null} + */ + public function entriesOutsideContract(int $agentId, ?string $debut, ?string $fin): array + { + $stmt = $this->pdo->prepare( + 'SELECT COUNT(*) AS nombre, MIN(c.date_jour) AS premiere, MAX(c.date_jour) AS derniere + FROM creneau_horaire c + JOIN planning p ON p.id_planning = c.id_planning + WHERE p.id_agent = :agent_id + AND ((:debut IS NOT NULL AND c.date_jour < :debut_compare) + OR (:fin IS NOT NULL AND c.date_jour > :fin_compare))' + ); + // Chaque paramètre nommé est répété sous deux noms : PDO tourne sans + // émulation, un même nom ne peut pas apparaître deux fois. + $stmt->execute([ + 'agent_id' => $agentId, + 'debut' => $debut, + 'debut_compare' => $debut, + 'fin' => $fin, + 'fin_compare' => $fin, + ]); + $ligne = $stmt->fetch() ?: []; + + return [ + 'nombre' => (int) ($ligne['nombre'] ?? 0), + 'premiere' => $ligne['premiere'] ?? null, + 'derniere' => $ligne['derniere'] ?? null, + ]; + } + /** * Projection commune à toutes les lectures d'agent, complétée par la clause * WHERE de chaque appelant. @@ -375,6 +518,10 @@ final class AgentModel extends BaseModel * bindValue() explicite ici (et non un tableau passé à execute()) car * `structure_id` peut être NULL et exige PDO::PARAM_NULL. * + * Le nom et le prénom font partie des champs modifiables depuis la V7.13 ; + * le MATRICULE, lui, ne l'est jamais : c'est la clé de rapprochement avec + * les outils RH, et les plannings comme les PDF déjà produits s'y réfèrent. + * * @param array $data Champs déjà validés par le contrôleur. * * @throws \RuntimeException Si l'agent n'existe pas. @@ -395,7 +542,9 @@ final class AgentModel extends BaseModel $update = $this->pdo->prepare( 'UPDATE agent - SET email = :email, + SET nom = :nom, + prenom = :prenom, + email = :email, telephone = :telephone, adresse = :adresse, est_diplome = :est_diplome, @@ -410,6 +559,8 @@ final class AgentModel extends BaseModel WHERE id_agent = :agent_id' ); $update->bindValue(':agent_id', $agentId, PDO::PARAM_INT); + $update->bindValue(':nom', $data['nom']); + $update->bindValue(':prenom', $data['prenom']); $update->bindValue(':email', $data['email']); $update->bindValue(':telephone', $data['telephone']); $update->bindValue(':adresse', $data['adresse']); diff --git a/app/Models/CompteModel.php b/app/Models/CompteModel.php new file mode 100644 index 0000000..99cda69 --- /dev/null +++ b/app/Models/CompteModel.php @@ -0,0 +1,672 @@ +|null Le compte, ou null si le jeton est + * inconnu, révoqué ou porté par une + * identité inactive. + */ + public function authentifier(string $jeton): ?array + { + $jeton = self::normaliser($jeton); + if ($jeton === '') { + return null; + } + + $stmt = $this->pdo->prepare( + 'SELECT * FROM v_acces_compte + WHERE jeton_empreinte = :empreinte AND actif = 1 AND identite_active = 1 + LIMIT 1' + ); + $stmt->execute(['empreinte' => self::empreinte($jeton)]); + + return $stmt->fetch() ?: null; + } + + /** + * Relit un compte par son identifiant, pour la SECONDE étape de connexion. + * + * Les mêmes conditions qu'à l'authentification par jeton s'appliquent — actif + * et identité active : entre la saisie du jeton et celle du mot de passe, la + * DSI a pu révoquer le compte. La ligne est renvoyée brute, avec son hachage, + * là où trouver() la met en forme pour l'API et le retire. + * + * @return array|null + */ + public function trouverPourAuthentification(int $compteId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM v_acces_compte + WHERE id_compte = :id AND actif = 1 AND identite_active = 1 + LIMIT 1' + ); + $stmt->execute(['id' => $compteId]); + + return $stmt->fetch() ?: null; + } + + /** Horodate une connexion réussie et incrémente son compteur. */ + public function enregistrerConnexion(int $compteId): void + { + $stmt = $this->pdo->prepare( + 'UPDATE compte_acces + SET date_derniere_connexion = NOW(), + nombre_connexions = nombre_connexions + 1 + WHERE id_compte = :id' + ); + $stmt->execute(['id' => $compteId]); + } + + /** + * Liste des comptes pour la page DSI. + * + * @param array $filtres role, actif (0|1), q (texte libre). + * + * @return array> + */ + public function lister(array $filtres = []): array + { + $conditions = []; + $params = []; + + $role = (string) ($filtres['role'] ?? ''); + if ($role !== '') { + $conditions[] = 'role = :role'; + $params['role'] = $role; + } + + if (isset($filtres['actif']) && $filtres['actif'] !== '') { + $conditions[] = 'actif = :actif'; + $params['actif'] = (int) $filtres['actif']; + } + + $recherche = trim((string) ($filtres['q'] ?? '')); + if ($recherche !== '') { + $conditions[] = '(identite_libelle LIKE :q OR libelle LIKE :q OR matricule LIKE :q OR jeton_prefixe LIKE :q)'; + $params['q'] = '%' . $recherche . '%'; + } + + $where = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions); + $stmt = $this->pdo->prepare( + 'SELECT id_compte, role, libelle, identite_libelle, identite_active, id_agent, id_structure, + matricule, structure_nom, jeton_prefixe, jeton_empreinte, actif, commentaire, + date_creation, date_rotation, date_derniere_connexion, nombre_connexions, + mot_de_passe_requis, mot_de_passe_defini, date_mot_de_passe + FROM v_acces_compte' . $where . ' + ORDER BY FIELD(role, \'DSI\', \'SERVICE_ENFANCE\', \'RESPONSABLE_STRUCTURE\', \'AGENT\'), identite_libelle' + ); + $stmt->execute($params); + + return array_map( + fn (array $ligne): array => $this->presenter($ligne), + $stmt->fetchAll() + ); + } + + /** + * Un compte par son identifiant. + * + * @return array|null + */ + public function trouver(int $compteId): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM v_acces_compte WHERE id_compte = :id LIMIT 1'); + $stmt->execute(['id' => $compteId]); + $compte = $stmt->fetch(); + + return $compte === false ? null : $this->presenter($compte); + } + + /** + * Identités actives encore dépourvues de compte. + * + * @return array> + */ + public function manquants(): array + { + $stmt = $this->pdo->query( + 'SELECT role, id_agent, id_structure, libelle FROM v_acces_compte_manquant ORDER BY role, libelle' + ); + + return $stmt->fetchAll(); + } + + /** + * Crée un compte et retourne le jeton EN CLAIR — unique occasion de le lire. + * + * @return array{compte: array, jeton: string} + * + * @throws DomainException Si le rôle, l'identité ou l'unicité ne conviennent pas. + */ + public function creer( + string $role, + ?int $agentId, + ?int $structureId, + string $libelle, + ?string $commentaire = null + ): array { + $role = strtoupper(trim($role)); + $libelle = trim($libelle); + + if (!in_array($role, [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE, Access::ROLE_DSI], true)) { + throw new DomainException('Rôle inconnu.'); + } + + // Un compte AGENT ou RESPONSABLE_STRUCTURE tire son périmètre de son + // identité : sans identité, la session n'aurait aucun lieu ni aucun + // agent à borner. Les deux autres rôles n'ont pas de périmètre à borner. + if ($role === Access::ROLE_AGENT) { + $structureId = null; + if ($agentId === null) { + throw new DomainException('Choisissez l’agent titulaire du jeton.'); + } + $libelle = $this->libelleAgent($agentId); + } elseif ($role === Access::ROLE_RESPONSABLE) { + $agentId = null; + if ($structureId === null) { + throw new DomainException('Choisissez le lieu d’affectation titulaire du jeton.'); + } + $libelle = $this->libelleStructure($structureId); + } else { + $agentId = null; + $structureId = null; + if ($libelle === '') { + throw new DomainException('Indiquez le nom du titulaire du jeton.'); + } + } + + $jeton = self::genererJeton(); + $stmt = $this->pdo->prepare( + 'INSERT INTO compte_acces (role, libelle, id_agent, id_structure, jeton_empreinte, jeton_prefixe, commentaire) + VALUES (:role, :libelle, :id_agent, :id_structure, :empreinte, :prefixe, :commentaire)' + ); + + try { + $stmt->execute([ + 'role' => $role, + 'libelle' => mb_substr($libelle, 0, 180), + 'id_agent' => $agentId, + 'id_structure' => $structureId, + 'empreinte' => self::empreinte($jeton), + 'prefixe' => self::prefixe($jeton), + 'commentaire' => $commentaire !== null && trim($commentaire) !== '' + ? mb_substr(trim($commentaire), 0, 255) + : null, + ]); + } catch (\PDOException $e) { + // 23000 = violation de contrainte : les index uniques sur id_agent et + // id_structure garantissent un seul compte par identité. + if ($e->getCode() === '23000') { + throw new DomainException('Cette identité possède déjà un compte d’accès.'); + } + throw $e; + } + + $compte = $this->trouver((int) $this->pdo->lastInsertId()); + + return ['compte' => $compte ?? [], 'jeton' => $jeton]; + } + + /** + * Régénère le jeton d'un compte : l'ancien cesse immédiatement de + * fonctionner, y compris pour les sessions ouvertes — Access relit le compte + * à chaque requête, mais c'est l'empreinte qui a changé, pas le compte. + * + * @return string Le nouveau jeton, en clair, à transmettre au titulaire. + * + * @throws DomainException Si le compte n'existe pas. + */ + public function regenererJeton(int $compteId): string + { + if ($this->trouver($compteId) === null) { + throw new DomainException('Compte introuvable.'); + } + + $jeton = self::genererJeton(); + $stmt = $this->pdo->prepare( + 'UPDATE compte_acces + SET jeton_empreinte = :empreinte, + jeton_prefixe = :prefixe, + date_rotation = NOW() + WHERE id_compte = :id' + ); + $stmt->execute([ + 'empreinte' => self::empreinte($jeton), + 'prefixe' => self::prefixe($jeton), + 'id' => $compteId, + ]); + + return $jeton; + } + + /** + * Active ou désactive un compte. + * + * @throws DomainException Si l'opération laisserait l'application sans + * aucune DSI en état de se connecter. + */ + public function definirActif(int $compteId, bool $actif): void + { + $compte = $this->trouver($compteId); + if ($compte === null) { + throw new DomainException('Compte introuvable.'); + } + + if (!$actif) { + $this->assurerDerniereDsi($compte); + } + + $stmt = $this->pdo->prepare('UPDATE compte_acces SET actif = :actif WHERE id_compte = :id'); + $stmt->execute(['actif' => $actif ? 1 : 0, 'id' => $compteId]); + } + + /** + * Supprime définitivement un compte. Les traces qu'il a laissées dans le + * journal SURVIVENT : la clé étrangère est en ON DELETE SET NULL et le + * libellé de l'acteur y est figé. + * + * @throws DomainException Si c'est le dernier accès DSI actif. + */ + public function supprimer(int $compteId): void + { + $compte = $this->trouver($compteId); + if ($compte === null) { + throw new DomainException('Compte introuvable.'); + } + + $this->assurerDerniereDsi($compte); + + $stmt = $this->pdo->prepare('DELETE FROM compte_acces WHERE id_compte = :id'); + $stmt->execute(['id' => $compteId]); + } + + /** + * Génère en une passe un jeton pour chaque agent et chaque lieu actif qui + * n'en a pas. C'est l'outil de MISE EN SERVICE : sans lui, il faudrait créer + * les comptes un par un avant que quiconque puisse se connecter. + * + * @return array + * Les jetons produits, en clair. Cette liste est la seule occasion + * de les lire : elle doit être distribuée puis refermée. + */ + public function genererManquants(): array + { + $produits = []; + + foreach ($this->manquants() as $identite) { + $role = (string) $identite['role']; + $agentId = $identite['id_agent'] !== null ? (int) $identite['id_agent'] : null; + $structureId = $identite['id_structure'] !== null ? (int) $identite['id_structure'] : null; + + $resultat = $this->creer($role, $agentId, $structureId, (string) $identite['libelle']); + $produits[] = [ + 'id_compte' => (int) ($resultat['compte']['id_compte'] ?? 0), + 'role' => $role, + 'libelle' => (string) ($resultat['compte']['identite_libelle'] ?? $identite['libelle']), + 'jeton' => $resultat['jeton'], + ]; + } + + return $produits; + } + + /** Nombre de comptes DSI actifs, hors compte $exclu. */ + public function compterDsiActives(?int $exclu = null): int + { + // Zéro plutôt que NULL : PDO tourne sans émulation de requêtes préparées, + // un même paramètre nommé ne peut donc pas être répété dans la requête, + // et aucun compte ne porte l'identifiant 0. + $stmt = $this->pdo->prepare( + 'SELECT COUNT(*) FROM v_acces_compte + WHERE role = :role AND actif = 1 AND identite_active = 1 + AND id_compte <> :exclu' + ); + $stmt->execute(['role' => Access::ROLE_DSI, 'exclu' => $exclu ?? 0]); + + return (int) $stmt->fetchColumn(); + } + + /** + * Vrai tant qu'un compte actif porte encore le jeton d'installation, dont + * l'empreinte est écrite en clair dans la migration versionnée — donc + * publique. L'interface DSI s'en sert pour alerter en rouge. + * + * La question est posée à la base plutôt qu'à la liste affichée : un filtre + * de recherche ne doit pas faire disparaître l'alerte. + */ + public function installationEnCours(): bool + { + $stmt = $this->pdo->prepare( + 'SELECT 1 FROM compte_acces + WHERE jeton_empreinte = :empreinte AND actif = 1 + LIMIT 1' + ); + $stmt->execute(['empreinte' => self::EMPREINTE_INSTALLATION]); + + return $stmt->fetchColumn() !== false; + } + + /** + * Ce compte réclame-t-il un mot de passe en plus de son jeton ? + * + * La règle vit dans la vue (`mot_de_passe_requis`), pas ici : la liste des + * rôles concernés se modifie en SQL, comme le reste des droits depuis la + * V7.8. + * + * @param array $compte Ligne de v_acces_compte. + */ + public static function exigeMotDePasse(array $compte): bool + { + return (int) ($compte['mot_de_passe_requis'] ?? 0) === 1; + } + + /** + * Le titulaire a-t-il déjà choisi son mot de passe ? + * Faux à la création du compte comme après une réinitialisation par la DSI : + * la connexion suivante impose alors d'en définir un. + * + * @param array $compte Ligne de v_acces_compte. + */ + public static function motDePasseDefini(array $compte): bool + { + return (int) ($compte['mot_de_passe_defini'] ?? 0) === 1; + } + + /** + * Vérifie le mot de passe présenté, et remet l'empreinte à niveau si PHP a + * changé d'algorithme par défaut depuis sa création. + * + * @param array $compte Ligne de v_acces_compte, avec son hachage. + */ + public function verifierMotDePasse(array $compte, string $motDePasse): bool + { + $hash = (string) ($compte['mot_de_passe_hash'] ?? ''); + if ($hash === '' || !password_verify($motDePasse, $hash)) { + return false; + } + + if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { + $this->enregistrerHash((int) $compte['id_compte'], password_hash($motDePasse, PASSWORD_DEFAULT)); + } + + return true; + } + + /** + * Enregistre le mot de passe choisi par le titulaire. + * + * @throws DomainException Si la politique n'est pas respectée. + */ + public function definirMotDePasse(int $compteId, string $motDePasse, string $confirmation): void + { + if ($motDePasse !== $confirmation) { + throw new DomainException('Les deux mots de passe saisis ne sont pas identiques.'); + } + + $this->assurerPolitique($motDePasse); + $this->enregistrerHash($compteId, password_hash($motDePasse, PASSWORD_DEFAULT)); + } + + /** + * Efface le mot de passe d'un compte : la DSI ne le remplace pas, elle le + * RETIRE. Le titulaire en choisira un nouveau à sa prochaine connexion, avec + * son jeton — la DSI ne connaît donc jamais le mot de passe de personne. + * + * @throws DomainException Si le compte n'existe pas ou n'a pas de mot de passe. + */ + public function reinitialiserMotDePasse(int $compteId): array + { + $compte = $this->trouver($compteId); + if ($compte === null) { + throw new DomainException('Compte introuvable.'); + } + if (!$compte['mot_de_passe_requis']) { + throw new DomainException('Ce type de compte n’utilise pas de mot de passe.'); + } + + $stmt = $this->pdo->prepare( + 'UPDATE compte_acces SET mot_de_passe_hash = NULL, date_mot_de_passe = NULL WHERE id_compte = :id' + ); + $stmt->execute(['id' => $compteId]); + + return $compte; + } + + /** + * Politique de mot de passe : la longueur, et rien d'autre. + * + * @throws DomainException Si le mot de passe est trop court, trop long ou + * trop proche du jeton. + */ + private function assurerPolitique(string $motDePasse): void + { + // Mesure en OCTETS et non en caractères : c'est la limite de bcrypt, et + // un accent compte pour deux. + $longueur = strlen($motDePasse); + + if ($longueur < self::MOT_DE_PASSE_MINIMUM) { + throw new DomainException(sprintf( + 'Le mot de passe doit contenir au moins %d caractères.', + self::MOT_DE_PASSE_MINIMUM + )); + } + if ($longueur > self::MOT_DE_PASSE_MAXIMUM) { + throw new DomainException(sprintf( + 'Le mot de passe ne peut pas dépasser %d caractères.', + self::MOT_DE_PASSE_MAXIMUM + )); + } + if (str_starts_with(self::normaliser($motDePasse), 'PTA-')) { + // Recopier son jeton dans le champ mot de passe ramènerait les deux + // facteurs à un seul secret. + throw new DomainException('Le mot de passe ne peut pas être votre jeton d’accès.'); + } + } + + /** Écrit une empreinte de mot de passe et horodate le changement. */ + private function enregistrerHash(int $compteId, string $hash): void + { + $stmt = $this->pdo->prepare( + 'UPDATE compte_acces + SET mot_de_passe_hash = :hash, date_mot_de_passe = NOW() + WHERE id_compte = :id' + ); + $stmt->execute(['hash' => $hash, 'id' => $compteId]); + } + + /** Un jeton en clair, prêt à être transmis : « PTA-XXXXXX-XXXXXX-… ». */ + public static function genererJeton(): string + { + $groupes = []; + for ($groupe = 0; $groupe < self::GROUPES; $groupe++) { + $caracteres = ''; + for ($index = 0; $index < self::TAILLE_GROUPE; $index++) { + // random_int() et non rand() : le tirage doit être imprévisible. + $caracteres .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)]; + } + $groupes[] = $caracteres; + } + + return 'PTA-' . implode('-', $groupes); + } + + /** Empreinte stockée en base. */ + public static function empreinte(string $jeton): string + { + return hash('sha256', self::normaliser($jeton)); + } + + /** + * Nettoie un jeton saisi à la main : espaces, casse et espaces insécables + * collés par un copier-coller depuis un courriel. + */ + public static function normaliser(string $jeton): string + { + $jeton = str_replace(["\u{00A0}", ' ', "\t", "\r", "\n"], '', $jeton); + + return strtoupper(trim($jeton)); + } + + /** Début du jeton, conservé en base pour l'identifier sans le révéler. */ + private static function prefixe(string $jeton): string + { + return mb_substr(self::normaliser($jeton), 0, 11); + } + + /** + * Met en forme une ligne de v_acces_compte pour l'API : types corrects, + * empreinte remplacée par le seul drapeau qui intéresse l'interface. + * + * @param array $ligne + * + * @return array + */ + private function presenter(array $ligne): array + { + return [ + 'id_compte' => (int) $ligne['id_compte'], + 'role' => (string) $ligne['role'], + 'libelle' => (string) $ligne['libelle'], + 'identite_libelle' => (string) ($ligne['identite_libelle'] ?? $ligne['libelle']), + 'identite_active' => (int) ($ligne['identite_active'] ?? 1) === 1, + 'id_agent' => $ligne['id_agent'] !== null ? (int) $ligne['id_agent'] : null, + 'id_structure' => $ligne['id_structure'] !== null ? (int) $ligne['id_structure'] : null, + 'matricule' => $ligne['matricule'] ?? null, + 'structure_nom' => $ligne['structure_nom'] ?? null, + 'jeton_prefixe' => (string) $ligne['jeton_prefixe'], + // Le jeton d'installation est public : l'interface doit pouvoir + // crier tant qu'il n'a pas été remplacé. + 'jeton_installation' => (string) $ligne['jeton_empreinte'] === self::EMPREINTE_INSTALLATION, + 'actif' => (int) $ligne['actif'] === 1, + // Le hachage ne sort jamais du serveur : l'interface n'a besoin que + // de savoir si un mot de passe est exigé et s'il a été choisi. + 'mot_de_passe_requis' => (int) ($ligne['mot_de_passe_requis'] ?? 0) === 1, + 'mot_de_passe_defini' => (int) ($ligne['mot_de_passe_defini'] ?? 0) === 1, + 'date_mot_de_passe' => $ligne['date_mot_de_passe'] ?? null, + 'commentaire' => $ligne['commentaire'] ?? null, + 'date_creation' => $ligne['date_creation'], + 'date_rotation' => $ligne['date_rotation'], + 'date_derniere_connexion' => $ligne['date_derniere_connexion'], + 'nombre_connexions' => (int) $ligne['nombre_connexions'], + ]; + } + + /** + * Interdit de se couper soi-même la branche : le dernier accès DSI actif ne + * peut être ni désactivé ni supprimé. Sans ce garde-fou, il faudrait rejouer + * la migration pour récupérer la main. + * + * @param array $compte + */ + private function assurerDerniereDsi(array $compte): void + { + if ((string) $compte['role'] !== Access::ROLE_DSI || !$compte['actif']) { + return; + } + + if ($this->compterDsiActives((int) $compte['id_compte']) === 0) { + throw new DomainException( + 'Ce compte est le dernier accès DSI actif : créez-en un autre avant de le retirer.' + ); + } + } + + /** Libellé d'un agent actif, ou refus si l'agent n'existe pas ou est inactif. */ + private function libelleAgent(int $agentId): string + { + $stmt = $this->pdo->prepare( + 'SELECT CONCAT(prenom, \' \', nom) FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1' + ); + $stmt->execute(['id' => $agentId]); + $libelle = $stmt->fetchColumn(); + + if ($libelle === false) { + throw new DomainException('Agent introuvable ou inactif.'); + } + + return (string) $libelle; + } + + /** Libellé d'un lieu actif, ou refus si le lieu n'existe pas ou est fermé. */ + private function libelleStructure(int $structureId): string + { + $stmt = $this->pdo->prepare( + 'SELECT nom FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1' + ); + $stmt->execute(['id' => $structureId]); + $libelle = $stmt->fetchColumn(); + + if ($libelle === false) { + throw new DomainException('Lieu d’affectation introuvable ou inactif.'); + } + + return (string) $libelle; + } +} diff --git a/app/Models/JournalModel.php b/app/Models/JournalModel.php new file mode 100644 index 0000000..69d3031 --- /dev/null +++ b/app/Models/JournalModel.php @@ -0,0 +1,265 @@ + $filtres categorie, resultat, role, id_compte, + * q (texte libre), du, au (AAAA-MM-JJ), + * page, taille. + * + * @return array{entrees: array>, total: int, page: int, taille: int, pages: int} + */ + public function rechercher(array $filtres = []): array + { + [$where, $params] = $this->conditions($filtres); + + $taille = (int) ($filtres['taille'] ?? self::TAILLE_PAGE); + $taille = max(10, min(self::TAILLE_PAGE_MAX, $taille)); + $page = max(1, (int) ($filtres['page'] ?? 1)); + + $total = $this->compter($where, $params); + $pages = $total === 0 ? 1 : (int) ceil($total / $taille); + // Une page vidée par un filtre plus restrictif ne doit pas renvoyer un + // écran blanc : on se replie sur la dernière page réellement peuplée. + $page = min($page, $pages); + + $stmt = $this->pdo->prepare( + 'SELECT id_journal, date_evenement, categorie, action, resultat, id_compte, role, + acteur, cible_table, cible_id, description, contexte, adresse_ip, + agent_utilisateur, jeton_prefixe + FROM v_journal_activite' . $where . ' + ORDER BY date_evenement DESC, id_journal DESC + LIMIT :limite OFFSET :decalage' + ); + + foreach ($params as $nom => $valeur) { + $stmt->bindValue(':' . $nom, $valeur); + } + // LIMIT et OFFSET doivent être liés en ENTIER : sans émulation de + // requêtes préparées, une valeur liée en chaîne produirait un LIMIT '50' + // que MariaDB refuse. + $stmt->bindValue(':limite', $taille, PDO::PARAM_INT); + $stmt->bindValue(':decalage', ($page - 1) * $taille, PDO::PARAM_INT); + $stmt->execute(); + + return [ + 'entrees' => array_map([$this, 'presenter'], $stmt->fetchAll()), + 'total' => $total, + 'page' => $page, + 'taille' => $taille, + 'pages' => $pages, + ]; + } + + /** + * Quelques chiffres de tête pour le bandeau de la page DSI. + * + * @return array + */ + public function statistiques(): array + { + $total = (int) $this->pdo->query('SELECT COUNT(*) FROM journal_activite')->fetchColumn(); + + $stmt = $this->pdo->query( + 'SELECT categorie, COUNT(*) AS nombre + FROM journal_activite + WHERE date_evenement >= NOW() - INTERVAL 7 DAY + GROUP BY categorie' + ); + $parCategorie = []; + foreach ($stmt->fetchAll() as $ligne) { + $parCategorie[(string) $ligne['categorie']] = (int) $ligne['nombre']; + } + + $refus24h = (int) $this->pdo->query( + 'SELECT COUNT(*) FROM journal_activite + WHERE resultat = \'REFUS\' AND date_evenement >= NOW() - INTERVAL 1 DAY' + )->fetchColumn(); + + $connexions24h = (int) $this->pdo->query( + 'SELECT COUNT(*) FROM journal_activite + WHERE categorie = \'CONNEXION\' AND resultat = \'SUCCES\' + AND date_evenement >= NOW() - INTERVAL 1 DAY' + )->fetchColumn(); + + $plusAncienne = $this->pdo->query( + 'SELECT MIN(date_evenement) FROM journal_activite' + )->fetchColumn(); + + return [ + 'total' => $total, + 'sept_jours' => $parCategorie, + 'refus_24h' => $refus24h, + 'connexions_24h' => $connexions24h, + 'plus_ancienne' => $plusAncienne ?: null, + ]; + } + + /** + * Nombre de secrets refusés pour une adresse IP sur les dernières minutes. + * + * C'est la mesure qui alimente le frein anti-force-brute de la page de + * connexion : le journal sert ici de compteur, sans table supplémentaire. + * Les deux facteurs comptent dans le même compteur — jeton refusé (V7.11) et + * mot de passe refusé (V7.12) : essayer mille mots de passe derrière un jeton + * volé doit buter sur le même mur qu'essayer mille jetons. + */ + public function echecsRecents(string $adresseIp, int $minutes): int + { + if ($adresseIp === '') { + return 0; + } + + $stmt = $this->pdo->prepare( + 'SELECT COUNT(*) FROM journal_activite + WHERE categorie = \'CONNEXION\' + AND action IN (\'JETON_REFUSE\', \'MOT_DE_PASSE_REFUSE\') + AND adresse_ip = :ip + AND date_evenement >= NOW() - INTERVAL :minutes MINUTE' + ); + $stmt->bindValue(':ip', $adresseIp); + $stmt->bindValue(':minutes', $minutes, PDO::PARAM_INT); + $stmt->execute(); + + return (int) $stmt->fetchColumn(); + } + + /** + * Purge les entrées antérieures à $jours jours. + * + * Le plancher de 30 jours est volontaire : une purge trop courte reviendrait + * à effacer les traces d'un incident avant même qu'il soit constaté. + * + * @return int Nombre de lignes supprimées. + */ + public function purger(int $jours): int + { + $jours = max(30, $jours); + + $stmt = $this->pdo->prepare( + 'DELETE FROM journal_activite WHERE date_evenement < NOW() - INTERVAL :jours DAY' + ); + $stmt->bindValue(':jours', $jours, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->rowCount(); + } + + /** + * Construit la clause WHERE commune à la recherche et au comptage. + * + * @param array $filtres + * + * @return array{0: string, 1: array} + */ + private function conditions(array $filtres): array + { + $conditions = []; + $params = []; + + foreach (['categorie' => 'categorie', 'resultat' => 'resultat', 'role' => 'role'] as $filtre => $colonne) { + $valeur = trim((string) ($filtres[$filtre] ?? '')); + if ($valeur !== '') { + $conditions[] = $colonne . ' = :' . $filtre; + $params[$filtre] = $valeur; + } + } + + $compteId = (int) ($filtres['id_compte'] ?? 0); + if ($compteId > 0) { + $conditions[] = 'id_compte = :id_compte'; + $params['id_compte'] = $compteId; + } + + $du = trim((string) ($filtres['du'] ?? '')); + if ($du !== '') { + $conditions[] = 'date_evenement >= :du'; + $params['du'] = $du . ' 00:00:00'; + } + + $au = trim((string) ($filtres['au'] ?? '')); + if ($au !== '') { + $conditions[] = 'date_evenement <= :au'; + $params['au'] = $au . ' 23:59:59'; + } + + $recherche = trim((string) ($filtres['q'] ?? '')); + if ($recherche !== '') { + $conditions[] = '(acteur LIKE :q OR action LIKE :q OR description LIKE :q OR adresse_ip LIKE :q)'; + $params['q'] = '%' . $recherche . '%'; + } + + return [$conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions), $params]; + } + + /** + * @param array $params + */ + private function compter(string $where, array $params): int + { + $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM v_journal_activite' . $where); + $stmt->execute($params); + + return (int) $stmt->fetchColumn(); + } + + /** + * Met en forme une entrée pour l'API. `contexte` est décodé ici : le client + * reçoit un objet, pas une chaîne JSON à réanalyser. + * + * @param array $ligne + * + * @return array + */ + private function presenter(array $ligne): array + { + $contexte = null; + if (!empty($ligne['contexte'])) { + $decode = json_decode((string) $ligne['contexte'], true); + $contexte = is_array($decode) ? $decode : null; + } + + return [ + 'id_journal' => (int) $ligne['id_journal'], + 'date_evenement' => (string) $ligne['date_evenement'], + 'categorie' => (string) $ligne['categorie'], + 'action' => (string) $ligne['action'], + 'resultat' => (string) $ligne['resultat'], + 'id_compte' => $ligne['id_compte'] !== null ? (int) $ligne['id_compte'] : null, + 'role' => $ligne['role'], + 'acteur' => $ligne['acteur'], + 'cible_table' => $ligne['cible_table'], + 'cible_id' => $ligne['cible_id'] !== null ? (int) $ligne['cible_id'] : null, + 'description' => $ligne['description'], + 'contexte' => $contexte, + 'adresse_ip' => $ligne['adresse_ip'], + 'agent_utilisateur' => $ligne['agent_utilisateur'], + 'jeton_prefixe' => $ligne['jeton_prefixe'], + ]; + } +} diff --git a/app/Models/PlanningModel.php b/app/Models/PlanningModel.php index 3342dac..cb35dca 100644 --- a/app/Models/PlanningModel.php +++ b/app/Models/PlanningModel.php @@ -437,6 +437,34 @@ final class PlanningModel extends BaseModel * * @return array */ + /** + * Créneaux du planning tombant hors des dates de contrat de leur agent. + * + * Contrôle de VALIDATION, complémentaire des gardes posées à l'écriture + * (V7.15) : celles-ci empêchent d'en créer de nouveaux, celui-ci révèle ceux + * qui existaient déjà — saisis avant la règle, ou rendus illégitimes par un + * contrat raccourci depuis. La comparaison est faite en SQL, sur la fiche + * agent jointe : c'est elle qui fait foi au moment du contrôle. + * + * @return array + */ + public function entriesOutsideContract(int $planningId): array + { + $stmt = $this->pdo->prepare( + 'SELECT c.id_creneau, c.date_jour + FROM creneau_horaire c + JOIN planning p ON p.id_planning = c.id_planning + JOIN agent a ON a.id_agent = p.id_agent + WHERE c.id_planning = :id_planning + AND ((a.date_debut_contrat IS NOT NULL AND c.date_jour < a.date_debut_contrat) + OR (a.date_fin_contrat IS NOT NULL AND c.date_jour > a.date_fin_contrat)) + ORDER BY c.date_jour' + ); + $stmt->execute(['id_planning' => $planningId]); + + return $stmt->fetchAll(); + } + public function internalOverlaps(int $planningId): array { $stmt = $this->pdo->prepare( diff --git a/app/Models/SchemaModel.php b/app/Models/SchemaModel.php index 7f256fb..e073a17 100644 --- a/app/Models/SchemaModel.php +++ b/app/Models/SchemaModel.php @@ -56,6 +56,8 @@ final class SchemaModel extends BaseModel 'equipe_pta', 'equipe_pta_membre', 'structure_creneau_reference', + 'compte_acces', + 'journal_activite', ]; $placeholders = implode(',', array_fill(0, count($requiredTables), '?')); @@ -82,6 +84,7 @@ final class SchemaModel extends BaseModel 'planning' => ['date_validation'], 'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'droit_enveloppe_formation', 'commentaire_pta'], 'structure' => ['type_affectation'], + 'compte_acces' => ['mot_de_passe_hash'], ]; foreach ($requiredColumns as $table => $columns) { @@ -108,6 +111,7 @@ final class SchemaModel extends BaseModel } $this->assertAccessViews(); + $this->assertJetonViews(); } /** @@ -145,4 +149,39 @@ final class SchemaModel extends BaseModel ); } } + + /** + * Vues des comptes à jeton et du journal (V7.11). + * + * Même logique fail-closed que pour les vues de droits : sans elles, + * personne ne peut plus s'authentifier et aucune action ne serait tracée. + * Mieux vaut un message explicite au démarrage qu'une application ouverte + * sans journal. + */ + private function assertJetonViews(): void + { + $requiredViews = [ + 'v_acces_compte', + 'v_acces_compte_manquant', + 'v_journal_activite', + ]; + + $placeholders = implode(',', array_fill(0, count($requiredViews), '?')); + $stmt = $this->pdo->prepare( + "SELECT TABLE_NAME + FROM information_schema.VIEWS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ($placeholders)" + ); + $stmt->execute($requiredViews); + $missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN))); + + if ($missing) { + throw new \RuntimeException( + 'Comptes d’accès indisponibles : vue(s) manquante(s) : ' + . implode(', ', $missing) + . '. Exécutez database/migration_v7_9e_dsi_comptes_journal.sql.' + ); + } + } } diff --git a/app/Views/layouts/footer.php b/app/Views/layouts/footer.php index d28c879..f31ce0f 100644 --- a/app/Views/layouts/footer.php +++ b/app/Views/layouts/footer.php @@ -51,10 +51,10 @@ window.PTA_PAGE_CONFIG = $pageConfig, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>; - + - + - + diff --git a/app/Views/layouts/header.php b/app/Views/layouts/header.php index 12fdf8d..34f2bc2 100644 --- a/app/Views/layouts/header.php +++ b/app/Views/layouts/header.php @@ -24,6 +24,11 @@ /** @var string $csrfToken */ $isAgent = ($accessProfile['role'] ?? '') === 'AGENT'; +// Service Enfance et DSI ouvrent leur session avec un mot de passe (V7.12) : +// eux seuls ont quelque chose à changer. La liste des rôles concernés vit en +// base (v_acces_compte.mot_de_passe_requis) ; elle est recopiée ici pour un +// simple affichage, jamais pour décider d'un droit. +$peutChangerMotDePasse = in_array($accessProfile['role'] ?? '', ['SERVICE_ENFANCE', 'DSI'], true); $navigation = [ 'dashboard' => ['dashboard.php', 'Tableau de bord'], @@ -36,7 +41,10 @@ $navigation = [ 'pta' => ['pta.php', 'PTA détaillé'], 'teams' => ['teams.php', 'Équipes'], 'vacations' => ['vacations.php', 'Vacances'], + 'manage_agents' => ['manage_agents.php', 'Agents'], + 'manage_structures' => ['manage_structures.php', 'Lieux d’affectation'], 'administration' => ['administration.php', 'Administration'], + 'dsi' => ['dsi.php', 'Accès & journaux'], ]; /** @@ -49,7 +57,11 @@ $navigationGroups = [ 'Piloter' => ['dashboard', 'pending', 'coverage'], 'Planifier' => ['planning', 'teams'], 'Consulter' => ['agents', 'structures', 'annual', 'pta'], - 'Paramétrer' => ['vacations', 'administration'], + // « Management » regroupe l'administration des personnes et des lieux : ce + // qu'on crée et corrige au fil de l'eau, à distinguer des paramètres qu'on + // règle une fois pour l'année. + 'Management' => ['manage_agents', 'manage_structures'], + 'Paramétrer' => ['vacations', 'administration', 'dsi'], ]; $visibleGroups = []; @@ -92,7 +104,7 @@ $showHelpTab = in_array('faq', $allowedPages, true); <?= htmlspecialchars($pageTitle) ?> — PTA - +
@@ -113,12 +125,15 @@ $showHelpTab = in_array('faq', $allowedPages, true); - Changer de rôle + + Mot de passe + + Se déconnecter -
Cette page n’est pas disponible avec votre rôle actuel.
+
Cette page n'est pas disponible avec votre rôle actuel.