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 = = json_encode([
'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
-
+
-
+
-
+