This commit is contained in:
PTA
2026-08-20 17:00:06 +02:00
parent 5d8c0993a6
commit 94e7f09eb4
58 changed files with 5605 additions and 615 deletions

View File

@@ -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 `<head>`. **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.

20
api/dsi_compte_creer.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — crée un compte d'accès et renvoie SON JETON EN CLAIR (DSI).
* Charge utile : role, agent_id, structure_id, libelle, commentaire, csrf_token.
* Le jeton n'est lisible que dans cette réponse : la base n'en garde que l'empreinte.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->creerCompte();

19
api/dsi_compte_jeton.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — régénère le jeton d'un compte, révoquant l'ancien (DSI).
* Charge utile : compte_id, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->regenererJeton();

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — efface le mot de passe d'un compte Service Enfance ou DSI (DSI).
* Charge utile : compte_id, csrf_token.
*
* La DSI ne peut que RETIRER un mot de passe, jamais en poser un : le titulaire
* en choisit un nouveau à sa connexion suivante, avec son jeton inchangé.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->reinitialiserMotDePasse();

19
api/dsi_compte_statut.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — active, désactive ou supprime un compte d'accès (DSI).
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER), csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->changerStatutCompte();

19
api/dsi_comptes.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — comptes d'accès, identités sans compte et rôles proposés (DSI).
* Paramètres : role, actif, q.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->comptes();

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — génère un jeton pour chaque agent et chaque lieu actif sans compte (DSI).
* Charge utile : csrf_token. Outil de mise en service : la réponse porte tous
* les jetons produits, en clair, et une seule fois.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->genererJetonsManquants();

19
api/dsi_journal.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — recherche paginée dans le journal d'activité (DSI).
* Paramètres : categorie, resultat, role, id_compte, q, du, au, page, taille.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->journal();

19
api/dsi_journal_purge.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — purge les entrées du journal antérieures à N jours (DSI, minimum 30).
* Charge utile : jours, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->purgerJournal();

View File

@@ -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,
]);
}

View File

@@ -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,

View File

@@ -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),

View File

@@ -0,0 +1,394 @@
<?php
declare(strict_types=1);
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\CompteModel;
use App\Models\JournalModel;
use DomainException;
use PDO;
/**
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
*
* C'est de l'administration TECHNIQUE, pas fonctionnelle. La DSI distribue les
* accès et lit les traces ; elle ne voit ni planning, ni quota, ni agent —
* acces_role_page ne lui ouvre qu'une seule page.
*
* DEUX RÈGLES PROPRES À CE CONTRÔLEUR :
* 1. un jeton en clair ne sort d'ici QU'AU MOMENT où il vient d'être produit
* (création, régénération, génération en lot). Aucune lecture ne le
* retourne, parce que la base ne l'a plus ;
* 2. toute action y est journalisée en catégorie ADMINISTRATION, y compris la
* purge du journal — et sans jamais recopier le jeton dans la trace.
*/
final class DsiController
{
private Access $access;
private CompteModel $comptes;
private JournalModel $journal;
public function __construct(private PDO $pdo)
{
$this->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<int, array{code: string, libelle: string, identite: string}>
*/
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'],
];
}
}

View File

@@ -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),
],
]);
}

View File

@@ -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<int, array{niveau: string, code: string, message: string}> $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<string, mixed> $agent Fiche agent.
* @param array<int, string> $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<int, string>
*/
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];

View File

@@ -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()]);

View File

@@ -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<string, mixed> $compte Ligne de v_acces_compte.
* @param array<int, string> $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<string, mixed> $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<string, mixed>|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);
}
}

View File

@@ -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,

View File

@@ -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.']);
}
}

View File

@@ -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.']);
}

View File

@@ -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<string, mixed> $agent
* @param array<int, string> $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<string, mixed> $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)) {

View File

@@ -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<int, string> 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<string, array<int, string>> */
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<string, mixed>|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<string, mixed> $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<string, mixed> $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<string, mixed>|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;
}
/**

View File

@@ -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.']);
}

182
app/Core/Journal.php Normal file
View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
use Throwable;
/**
* Journal d'activité — écriture des traces dans journal_activite (V7.11).
*
* Trois familles d'événements sont tracées, et seulement celles-là :
* - CONNEXION : entrée par jeton, échec de jeton, déconnexion ;
* - ECRITURE : toute modification en base, avec la table et l'identifiant
* visés ;
* - ACCES_REFUSE : page interdite, rôle insuffisant, jeton CSRF invalide.
* S'y ajoutent ADMINISTRATION (actions de la DSI sur les comptes et le journal)
* et SYSTEME, réservé aux incidents techniques.
* Les LECTURES ne sont pas journalisées : elles représenteraient l'essentiel du
* volume pour une valeur d'audit très faible.
*
* DEUX RÈGLES ABSOLUES :
* 1. écrire dans le journal ne doit JAMAIS faire échouer l'action journalisée.
* Toute erreur d'insertion est avalée et renvoyée dans le journal serveur
* (error_log). Un journal indisponible dégrade l'audit, il ne bloque pas la
* planification des agents.
* 2. la trace est FIGÉE : rôle et libellé de l'acteur sont copiés au moment de
* l'action. Renommer un agent ou supprimer son compte ne réécrit pas
* l'histoire (d'où le ON DELETE SET NULL de la clé étrangère).
*
* La connexion PDO est posée une fois par app/bootstrap.php
* (Journal::useConnection()) : les appelants — y compris les gardes statiques
* comme Csrf — n'ont ainsi rien à transporter.
*/
final class Journal
{
public const CONNEXION = 'CONNEXION';
public const ECRITURE = 'ECRITURE';
public const ACCES_REFUSE = 'ACCES_REFUSE';
public const ADMINISTRATION = 'ADMINISTRATION';
public const SYSTEME = 'SYSTEME';
private static ?PDO $pdo = null;
/**
* Enregistre la connexion utilisée pour écrire les traces.
* Appelé par app/bootstrap.php, donc valable pour toute la requête.
*/
public static function useConnection(PDO $pdo): void
{
self::$pdo = $pdo;
}
/**
* Trace une entrée, une sortie ou un échec d'authentification.
*
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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);
}
}

View File

@@ -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<string, mixed> $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<int, string> $dates
*
* @return array<int, string>
*/
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<int, string> $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<string, mixed> $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']);

672
app/Models/CompteModel.php Normal file
View File

@@ -0,0 +1,672 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Core\Access;
use DomainException;
/**
* Comptes d'accès et jetons (V7.11).
*
* PRINCIPE : chaque utilisateur — agent, responsable de lieu, Service Enfance,
* DSI — reçoit un jeton unique tiré au sort par le serveur. Il n'y a AUCUN mot
* de passe dans PTA : rien à choisir, rien à renouveler, rien à ressaisir sur
* un post-it.
*
* Le jeton n'est stocké NULLE PART en clair. La base garde son empreinte
* SHA-256 et ses onze premiers caractères, qui servent uniquement à reconnaître
* une ligne dans la liste de la page DSI. Conséquence directe, à assumer dans
* l'interface : un jeton n'est affiché QU'UNE FOIS, au moment où il est produit.
* Perdu, il ne se retrouve pas — il se régénère, ce qui invalide le précédent.
*
* Pourquoi SHA-256 sans sel, alors qu'on hache d'ordinaire les secrets avec
* password_hash() : un jeton PTA est tiré sur 120 bits d'entropie par le
* serveur, il n'est pas devinable par dictionnaire comme le serait un mot de
* passe choisi par un humain. Le durcissement lent de password_hash() n'apporte
* donc rien ici, alors qu'il interdirait de retrouver le compte en une seule
* lecture d'index — il faudrait comparer le jeton présenté à CHAQUE compte.
*/
final class CompteModel extends BaseModel
{
/**
* Alphabet des jetons : chiffres et majuscules, MOINS I, O, 0 et 1.
* Les jetons sont recopiés à la main depuis un courriel ou un papier : les
* caractères qu'on confond à la lecture en sont exclus.
*/
private const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
/** 4 groupes de 6 caractères = 24 tirages sur 32 valeurs, soit 120 bits. */
private const GROUPES = 4;
private const TAILLE_GROUPE = 6;
/**
* Longueur minimale d'un mot de passe. La longueur prime sur la complexité :
* imposer une majuscule et un chiffre produit « Motdepasse1 », pas un secret.
*/
public const MOT_DE_PASSE_MINIMUM = 12;
/**
* Longueur maximale, imposée par bcrypt : password_hash() ne considère que
* les 72 PREMIERS OCTETS et ignore le reste EN SILENCE. Refuser au-delà vaut
* mieux que laisser croire qu'une phrase de 90 caractères protège mieux
* qu'un préfixe de 72.
*/
public const MOT_DE_PASSE_MAXIMUM = 72;
/**
* Empreinte du jeton d'installation posé par la migration V7.11.
* Sert uniquement à afficher l'alerte « ce jeton est public, régénérez-le »
* tant que le compte DSI initial n'a pas été renouvelé.
*/
public const EMPREINTE_INSTALLATION = '7f61ffc8efed4ed46d581a98286115ad03bb0122ffc70db3398584348aca431d';
/**
* Vérifie un jeton présenté à la connexion.
*
* La vue ne renvoie le compte que s'il est actif ET que l'identité derrière
* lui l'est aussi : un agent désactivé ne peut plus entrer, même avec un
* jeton techniquement valide.
*
* @return array<string, mixed>|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<string, mixed>|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<string, mixed> $filtres role, actif (0|1), q (texte libre).
*
* @return array<int, array<string, mixed>>
*/
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<string, mixed>|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<int, array<string, mixed>>
*/
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<string, mixed>, 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<int, array{role: string, libelle: string, jeton: string, id_compte: int}>
* 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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $ligne
*
* @return array<string, mixed>
*/
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<string, mixed> $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;
}
}

265
app/Models/JournalModel.php Normal file
View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Models;
use PDO;
/**
* Lecture du journal d'activité pour la page DSI (V7.11).
*
* Ce modèle ne fait que LIRE — l'écriture appartient à App\Core\Journal, qui
* n'est jamais loin d'une action métier. La seule exception est la purge, une
* opération d'exploitation déclenchée à la main par la DSI.
*
* Le volume est la contrainte de conception : un journal se consulte par
* tranches. Toutes les recherches sont donc paginées et bornées, et le compte
* total est demandé séparément plutôt que ramené ligne par ligne.
*/
final class JournalModel extends BaseModel
{
/** Taille de page par défaut, alignée sur ce qu'un écran affiche sans lassitude. */
public const TAILLE_PAGE = 50;
/** Plafond dur : aucune requête ne peut ramener davantage de lignes. */
private const TAILLE_PAGE_MAX = 200;
/**
* Recherche paginée dans le journal.
*
* @param array<string, mixed> $filtres categorie, resultat, role, id_compte,
* q (texte libre), du, au (AAAA-MM-JJ),
* page, taille.
*
* @return array{entrees: array<int, array<string, mixed>>, 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<string, mixed>
*/
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<string, mixed> $filtres
*
* @return array{0: string, 1: array<string, mixed>}
*/
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<string, mixed> $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<string, mixed> $ligne
*
* @return array<string, mixed>
*/
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'],
];
}
}

View File

@@ -437,6 +437,34 @@ final class PlanningModel extends BaseModel
*
* @return array<int, array{c1: int, c2: int, date_jour: string}>
*/
/**
* 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<int, array{id_creneau: int, date_jour: string}>
*/
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(

View File

@@ -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.'
);
}
}
}

View File

@@ -51,10 +51,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="assets/js/core.js?v=7.16"></script>
<script src="assets/js/core.js?v=7.23"></script>
<?php foreach ($scripts as $script): ?>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.16"></script>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.23"></script>
<?php endforeach; ?>
<script src="assets/js/page-bootstrap.js?v=7.16"></script>
<script src="assets/js/page-bootstrap.js?v=7.23"></script>
</body>
</html>

View File

@@ -24,6 +24,11 @@
/** @var string $csrfToken */
$isAgent = ($accessProfile['role'] ?? '') === 'AGENT';
// Service Enfance et DSI ouvrent leur session avec un mot de passe (V7.12) :
// eux seuls ont quelque chose à changer. La liste des rôles concernés vit en
// base (v_acces_compte.mot_de_passe_requis) ; elle est recopiée ici pour un
// simple affichage, jamais pour décider d'un droit.
$peutChangerMotDePasse = in_array($accessProfile['role'] ?? '', ['SERVICE_ENFANCE', 'DSI'], true);
$navigation = [
'dashboard' => ['dashboard.php', 'Tableau de bord'],
@@ -36,7 +41,10 @@ $navigation = [
'pta' => ['pta.php', 'PTA détaillé'],
'teams' => ['teams.php', 'Équipes'],
'vacations' => ['vacations.php', 'Vacances'],
'manage_agents' => ['manage_agents.php', 'Agents'],
'manage_structures' => ['manage_structures.php', 'Lieux d’affectation'],
'administration' => ['administration.php', 'Administration'],
'dsi' => ['dsi.php', 'Accès & journaux'],
];
/**
@@ -49,7 +57,11 @@ $navigationGroups = [
'Piloter' => ['dashboard', 'pending', 'coverage'],
'Planifier' => ['planning', 'teams'],
'Consulter' => ['agents', 'structures', 'annual', 'pta'],
'Paramétrer' => ['vacations', 'administration'],
// « Management » regroupe l'administration des personnes et des lieux : ce
// qu'on crée et corrige au fil de l'eau, à distinguer des paramètres qu'on
// règle une fois pour l'année.
'Management' => ['manage_agents', 'manage_structures'],
'Paramétrer' => ['vacations', 'administration', 'dsi'],
];
$visibleGroups = [];
@@ -92,7 +104,7 @@ $showHelpTab = in_array('faq', $allowedPages, true);
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.16">
<link rel="stylesheet" href="assets/style.css?v=7.23">
</head>
<body>
<main class="app-shell">
@@ -113,12 +125,15 @@ $showHelpTab = in_array('faq', $allowedPages, true);
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
<?php endif; ?>
</div>
<a class="button button-ghost switch-role-link" href="role.php?action=reset&amp;csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Changer de rôle</a>
<?php if ($peutChangerMotDePasse): ?>
<a class="button button-ghost switch-role-link" href="role.php?action=mot_de_passe">Mot de passe</a>
<?php endif; ?>
<a class="button button-ghost switch-role-link" href="role.php?action=reset&amp;csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Se déconnecter</a>
</div>
</header>
<?php if (!empty($_GET['access_denied'])): ?>
<div class="message message-warning role-access-warning">Cette page n’est pas disponible avec votre rôle actuel.</div>
<div class="message message-warning role-access-warning">Cette page n'est pas disponible avec votre rôle actuel.</div>
<?php endif; ?>
<nav class="view-tabs<?= $showGroupLabels ? ' view-tabs-grouped' : '' ?>" aria-label="Navigation principale">

9
app/Views/pages/dsi.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/dsi.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -0,0 +1,9 @@
<?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/manage_agents.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -0,0 +1,9 @@
<?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/manage_structures.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,131 +1,202 @@
<?php
/**
* Sélecteur de rôle — SEULE vue à ne pas passer par layouts/header.php.
* Écran de connexion — SEULE vue à ne pas passer par layouts/header.php.
*
* Elle s'affiche avant qu'un rôle soit choisi, donc avant tout contrôle
* d'accès, et ne peut pas afficher la barre de navigation qui en dépend. Elle
* porte donc son propre <head> : c'est le TROISIÈME endroit où incrémenter le
* `?v=` des assets, avec layouts/header.php et layouts/footer.php.
* Elle s'affiche avant qu'une session existe, donc avant tout contrôle d'accès,
* et ne peut pas afficher la barre de navigation qui en dépend. Elle porte donc
* son propre <head> : c'est le TROISIÈME endroit où incrémenter le `?v=` des
* assets, avec layouts/header.php et layouts/footer.php.
*
* Rappel : sélection n'est pas authentification. L'utilisateur choisit
* librement son identité (voir App\Core\Access::select()).
* Depuis la V7.11, ce n'est plus un sélecteur de rôle mais une AUTHENTIFICATION :
* l'utilisateur présente le jeton que la DSI lui a remis, et c'est son compte qui
* détermine son rôle et son périmètre.
*
* La V7.12 ajoute un second facteur pour les comptes Service Enfance et DSI.
* D'où les quatre états de cet écran, portés par $etape :
* « jeton » — saisie du jeton ;
* « 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.
* Un seul formulaire est affiché à la fois : les étapes sont exclusives.
*/
/** @var string $csrfToken */
/** @var array $agents */
/** @var array $structures */
/** @var string $error */
/** @var string $notice */
/** @var string $etape */
/** @var string $titulaire */
/** @var int $longueurMinimale */
/** @var array|null $currentProfile */
/** @var string $homeUrl */
/** @var string $cspNonce */
$titres = [
'jeton' => 'Connexion',
'definition' => 'Choisissez votre mot de passe',
'verification' => 'Mot de passe',
'changement' => 'Changer de mot de passe',
];
$titre = $titres[$etape] ?? 'Connexion';
?>
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choisir votre accès — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.16">
<title><?= htmlspecialchars($titre) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.23">
</head>
<body class="role-page-body">
<main class="role-page-shell">
<section class="role-intro">
<p class="eyebrow">PTA</p>
<h1>Quel est votre rôle ?</h1>
<p>Votre choix détermine les pages visibles et les actions autorisées pendant cette session.</p>
<?php if ($currentProfile): ?>
<h1><?= htmlspecialchars($titre) ?></h1>
<?php if ($etape === 'jeton'): ?>
<p>Saisissez le jeton d’accès qui vous a été remis. Il détermine votre rôle, les pages visibles et les actions autorisées.</p>
<?php elseif ($etape === 'definition'): ?>
<p>
Votre compte <strong><?= htmlspecialchars($titulaire) ?></strong> engage l’application :
il demande un second secret, connu de vous seul. Choisissez-le maintenant, il vous sera demandé à chaque connexion.
</p>
<?php elseif ($etape === 'verification'): ?>
<p>Jeton reconnu. Saisissez le mot de passe de <strong><?= htmlspecialchars($titulaire) ?></strong> pour ouvrir la session.</p>
<?php else: ?>
<p>Votre mot de passe actuel vous est demandé : sans lui, un poste laissé ouvert suffirait à confisquer votre compte.</p>
<?php endif; ?>
<?php if ($currentProfile && $etape === 'jeton'): ?>
<div class="role-current-profile">
Profil actuel : <strong><?= htmlspecialchars((string) $currentProfile['role_label']) ?></strong>
Session ouverte : <strong><?= htmlspecialchars((string) $currentProfile['role_label']) ?></strong>
— <?= htmlspecialchars((string) $currentProfile['label']) ?>
<a class="button button-primary role-resume-link" href="<?= htmlspecialchars($homeUrl) ?>">Reprendre</a>
</div>
<?php endif; ?>
<?php if ($notice !== ''): ?>
<div class="message message-success"><?= htmlspecialchars($notice) ?></div>
<?php endif; ?>
<?php if ($error !== ''): ?>
<div class="message message-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
</section>
<form method="post" class="role-selection-form" id="role-selection-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
<div class="role-choice-grid">
<label class="role-choice-card">
<input type="radio" name="role" value="AGENT" required>
<span class="role-choice-content">
<strong>Agent</strong>
<small>Lecture de mon planning et du planning de mon lieu de rattachement.</small>
</span>
</label>
<label class="role-choice-card">
<input type="radio" name="role" value="RESPONSABLE_STRUCTURE" required>
<span class="role-choice-content">
<strong>Responsable de structure</strong>
<small>Saisie et modification des horaires et motifs de mon lieu, uniquement en brouillon.</small>
</span>
</label>
<label class="role-choice-card">
<input type="radio" name="role" value="SERVICE_ENFANCE" required>
<span class="role-choice-content">
<strong>Service Enfance</strong>
<small>Contrôle complet, prévisionnel N+1, administration et validation des plannings.</small>
</span>
</label>
</div>
<div id="agent-context" class="role-context-card" hidden>
<?php if ($etape === 'jeton'): ?>
<form method="post" class="role-selection-form role-token-form" id="role-token-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
<label class="field">
<span>Je suis l’agent</span>
<select name="agent_id" id="role-agent-select">
<option value="">Choisir mon identité</option>
<?php foreach ($agents as $agent): ?>
<option value="<?= (int) $agent['id_agent'] ?>">
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' (' . $agent['matricule'] . ')') ?>
<?= !empty($agent['structure_nom']) ? ' — ' . htmlspecialchars((string) $agent['structure_nom']) : '' ?>
</option>
<?php endforeach; ?>
</select>
<span>Jeton d’accès</span>
<?php /* autocomplete="off" : un jeton n'a pas à être retenu par le navigateur d'un poste partagé. */ ?>
<input
type="text"
name="token"
id="role-token-input"
class="role-token-input"
placeholder="PTA-XXXXXX-XXXXXX-XXXXXX-XXXXXX"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
required
autofocus>
</label>
</div>
<div class="role-form-actions">
<button type="submit" class="button button-primary">Continuer</button>
</div>
</form>
<?php elseif ($etape === 'definition' || $etape === 'verification'): ?>
<form method="post" class="role-selection-form role-token-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
<input type="hidden" name="etape" value="mot_de_passe">
<div id="responsable-context" class="role-context-card" hidden>
<label class="field">
<span>Je suis responsable du lieu</span>
<select name="structure_id" id="role-structure-select">
<option value="">Choisir le lieu d’affectation</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>">
<?= htmlspecialchars((string) $structure['nom']) ?>
</option>
<?php endforeach; ?>
</select>
<span><?= $etape === 'definition' ? 'Nouveau mot de passe' : 'Mot de passe' ?></span>
<input
type="password"
name="mot_de_passe"
autocomplete="<?= $etape === 'definition' ? 'new-password' : 'current-password' ?>"
minlength="<?= (int) $longueurMinimale ?>"
required
autofocus>
</label>
</div>
<div class="role-form-actions">
<button type="submit" class="button button-primary">Accéder à PTA</button>
</div>
</form>
<?php if ($etape === 'definition'): ?>
<label class="field">
<span>Confirmation</span>
<input type="password" name="mot_de_passe_confirmation" autocomplete="new-password" minlength="<?= (int) $longueurMinimale ?>" required>
</label>
<p class="hint">
Au moins <?= (int) $longueurMinimale ?> caractères. Aucune règle de composition :
une phrase dont vous vous souvenez protège mieux qu’un mot court hérissé de symboles.
Il ne sera affiché nulle part — la DSI elle-même ne peut que l’effacer, jamais le lire.
</p>
<?php endif; ?>
<div class="role-form-actions">
<button type="submit" class="button button-primary"><?= $etape === 'definition' ? 'Enregistrer et se connecter' : 'Se connecter' ?></button>
</div>
<p class="hint role-step-back">
<a href="role.php?action=reset&amp;csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Recommencer avec un autre jeton</a>
</p>
</form>
<?php else: ?>
<form method="post" class="role-selection-form role-token-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
<input type="hidden" name="etape" value="changement">
<label class="field">
<span>Mot de passe actuel</span>
<input type="password" name="mot_de_passe_actuel" autocomplete="current-password" required autofocus>
</label>
<label class="field">
<span>Nouveau mot de passe</span>
<input type="password" name="mot_de_passe" autocomplete="new-password" minlength="<?= (int) $longueurMinimale ?>" required>
</label>
<label class="field">
<span>Confirmation</span>
<input type="password" name="mot_de_passe_confirmation" autocomplete="new-password" minlength="<?= (int) $longueurMinimale ?>" required>
</label>
<p class="hint">Au moins <?= (int) $longueurMinimale ?> caractères.</p>
<div class="role-form-actions">
<button type="submit" class="button button-primary">Changer le mot de passe</button>
</div>
<p class="hint role-step-back">
<a href="<?= htmlspecialchars($homeUrl) ?>">Revenir à l’application</a>
</p>
</form>
<?php endif; ?>
<aside class="role-security-note">
<strong>Version de démonstration :</strong> ce sélecteur organise les droits dans l’application, mais ne remplace pas une authentification nominative. En production, le rôle devra être associé au compte connecté.
<?php if ($etape === 'changement'): ?>
<strong>Un mot de passe oublié ne se retrouve pas :</strong> la DSI l’efface, et vous en choisissez un nouveau à votre connexion suivante, avec votre jeton.
<?php else: ?>
<strong>Jeton perdu ou compromis ?</strong> Contactez la DSI : elle en génère un nouveau, ce qui révoque immédiatement l’ancien.
Un jeton vaut pour une personne — il ne se prête pas, puisque toutes les actions sont journalisées à son nom.
Les comptes Service Enfance et DSI y ajoutent un mot de passe, que vous seul connaissez.
<?php endif; ?>
</aside>
</main>
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
(() => {
const agentContext = document.querySelector('#agent-context');
const responsableContext = document.querySelector('#responsable-context');
const agentSelect = document.querySelector('#role-agent-select');
const structureSelect = document.querySelector('#role-structure-select');
const input = document.querySelector('#role-token-input');
if (!input) return;
function refresh() {
const role = document.querySelector('input[name="role"]:checked')?.value || '';
agentContext.hidden = role !== 'AGENT';
responsableContext.hidden = role !== 'RESPONSABLE_STRUCTURE';
agentSelect.required = role === 'AGENT';
structureSelect.required = role === 'RESPONSABLE_STRUCTURE';
}
document.querySelectorAll('input[name="role"]').forEach((input) => input.addEventListener('change', refresh));
refresh();
/**
* Met en forme la saisie au fil de la frappe : majuscules et suppression des
* espaces. Les jetons sont recopiés depuis un courriel ou un papier, et un
* copier-coller ramène presque toujours une espace ou un retour à la ligne.
* Le serveur normalise de toute façon (CompteModel::normaliser) ; ceci n'est
* qu'un confort de saisie — et cela ne concerne QUE le jeton, jamais le mot
* de passe, dont chaque caractère compte tel qu'il est frappé.
*/
input.addEventListener('input', () => {
const position = input.selectionStart;
const nettoye = input.value.toUpperCase().replace(/\s+/g, '');
if (nettoye !== input.value) {
input.value = nettoye;
input.setSelectionRange(position, position);
}
});
})();
</script>
</body>

View File

@@ -1,192 +1,19 @@
<?php
/**
* Référentiels : agents, lieux d'affectation, postes, horaires de couverture.
* Modules JS : `administration` et `coverage`.
* Administration : horaires de couverture attendus des lieux.
* Module JS : `coverage`.
*
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
* posés ici sont les points d'ancrage du module JS : les renommer casse le
* module sans aucune erreur visible.
*
* V7.14 : les agents et les lieux d'affectation ont quitté cet écran pour la
* page « Management ». Ne restent ici que les règles de couverture, qui ne
* décrivent ni une personne ni un lieu mais une EXIGENCE DE PRÉSENCE — le
* paramètre que contrôle l'onglet « Couverture ».
*/
?>
<section id="assignment-view" class="app-view page-content">
<div class="admin-grid">
<section class="card admin-card">
<div class="section-heading">
<div>
<h2>Ajouter un lieu d’affectation</h2>
<p class="section-subtitle">Chaque lieu possède son propre type : périscolaire ou extrascolaire.</p>
</div>
</div>
<form id="create-structure-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Code <strong>*</strong></span>
<input id="new-structure-code" name="code" type="text" maxlength="50" placeholder="Ex. CRECHE_MATUSALEME" required>
</label>
<label class="field">
<span>Nom <strong>*</strong></span>
<input id="new-structure-name" name="nom" type="text" maxlength="150" placeholder="Ex. Crèche Matusalème" required>
</label>
<label class="field">
<span>Type d’affectation <strong>*</strong></span>
<select id="new-structure-type" name="type_affectation" required>
<option value="PERISCOLAIRE">Périscolaire</option>
<option value="EXTRASCOLAIRE">Extrascolaire</option>
</select>
</label>
<label class="field admin-field-wide">
<span>Adresse</span>
<input id="new-structure-address" name="adresse" type="text" maxlength="255" placeholder="Adresse du lieu">
</label>
</div>
<div class="admin-form-actions">
<div id="create-structure-message" class="message" role="status" aria-live="polite"></div>
<button class="button button-primary" type="submit">Ajouter le lieu</button>
</div>
</form>
</section>
<section class="card admin-card">
<div class="section-heading">
<div>
<h2>Ajouter un agent</h2>
<p class="section-subtitle">L’agent reçoit un lieu principal à sa création, mais peut être intégré aussi bien aux équipes périscolaires qu’extrascolaires et travailler ponctuellement sur d’autres lieux.</p>
</div>
</div>
<form id="create-agent-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Matricule <strong>*</strong></span>
<input id="new-agent-matricule" name="matricule" type="text" maxlength="50" required>
</label>
<label class="field">
<span>Poste PTA <strong>*</strong></span>
<select id="new-agent-post" name="id_poste" required>
<option value="">Choisir un poste</option>
<?php foreach ($posts as $post): ?>
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Type de contrat <strong>*</strong></span>
<select id="new-agent-contract-type" required>
<option value="PERMANENT">Permanent — soumis au PTA</option>
<option value="TEMPORAIRE">Temporaire — hors périmètre PTA</option>
</select>
</label>
<label class="field">
<span>Lieu d’affectation principal <strong>*</strong></span>
<select id="new-agent-structure" name="structure_id" required>
<option value="">Choisir un lieu d’affectation</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>">
<?= htmlspecialchars($structure['nom']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Nom <strong>*</strong></span>
<input id="new-agent-name" name="nom" type="text" maxlength="100" required>
</label>
<label class="field">
<span>Prénom <strong>*</strong></span>
<input id="new-agent-firstname" name="prenom" type="text" maxlength="100" required>
</label>
<label class="field">
<span>E-mail</span>
<input id="new-agent-email" name="email" type="email" maxlength="255" placeholder="prenom.nom@exemple.fr">
</label>
<label class="field">
<span>Téléphone</span>
<input id="new-agent-phone" name="telephone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
</label>
<label class="field">
<span>Qualification</span>
<select id="new-agent-qualified" name="est_diplome">
<option value="0">Non diplômé</option>
<option value="1">Diplômé</option>
</select>
</label>
<label class="field">
<span>Diplôme</span>
<input id="new-agent-diploma" name="diplome_libelle" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…" disabled>
</label>
<label class="field admin-field-wide">
<span>Adresse personnelle</span>
<textarea id="new-agent-address" name="adresse" maxlength="255" rows="2" placeholder="Adresse de l’agent"></textarea>
</label>
<label class="field">
<span>Quotité de travail <strong>*</strong></span>
<div class="input-suffix">
<input id="new-agent-rate" name="quotite_travail" type="number" min="1" max="100" step="0.01" value="100" required>
<span>%</span>
</div>
</label>
<label class="field">
<span>Date de début du contrat <strong>*</strong></span>
<input id="new-agent-contract-start" name="date_debut_contrat" type="date" value="<?= date('Y-m-d') ?>" required>
</label>
<label class="field">
<span>Date de fin du contrat</span>
<input id="new-agent-contract-end" name="date_fin_contrat" type="date">
</label>
<label class="check-field admin-field-wide">
<input id="new-agent-training-right" type="checkbox" checked>
<span>Droit à l’enveloppe de formation de 14 h. Décoché, le motif « Formation » ne pourra pas être affecté à cet agent lors de la saisie des horaires.</span>
</label>
<label class="field admin-field-wide">
<span>Observations utiles à la construction du PTA</span>
<textarea id="new-agent-pta-comment" maxlength="1000" rows="3" placeholder="Disponibilités particulières, organisation connue…"></textarea>
</label>
</div>
<p class="hint">Le quota PTA est suivi sur l’année civile, du 1er janvier au 31 décembre. À 100 %, la référence est de 1 607 h. La date de début du contrat reste une information RH mais ne décale pas l’année de référence.</p>
<div class="admin-form-actions">
<div id="create-agent-message" class="message" role="status" aria-live="polite"></div>
<button class="button button-primary" type="submit">Ajouter l’agent</button>
</div>
</form>
</section>
</div>
<section class="card">
<div class="section-heading">
<div>
<h2>Paramètres des lieux d’affectation</h2>
<p class="section-subtitle">Le type d’affectation est défini au niveau du lieu : par exemple « Crèche Matusalème — Périscolaire ».</p>
</div>
</div>
<div id="structure-settings-message" class="message" role="status" aria-live="polite"></div>
<div class="table-scroll">
<table class="planning-table assignment-table">
<thead>
<tr>
<th>Lieu</th>
<th>Code</th>
<th>Type d’affectation</th>
<th>Action</th>
</tr>
</thead>
<tbody id="structure-settings-body">
<?php foreach ($structures as $structure): ?>
<tr data-structure-id="<?= (int) $structure['id_structure'] ?>">
<td><strong><?= htmlspecialchars($structure['nom']) ?></strong></td>
<td><?= htmlspecialchars($structure['code']) ?></td>
<td>
<select class="structure-type-select" aria-label="Type d’affectation de <?= htmlspecialchars($structure['nom']) ?>">
<option value="PERISCOLAIRE" <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'PERISCOLAIRE' ? 'selected' : '' ?>>Périscolaire</option>
<option value="EXTRASCOLAIRE" <?= ($structure['type_affectation'] ?? '') === 'EXTRASCOLAIRE' ? 'selected' : '' ?>>Extrascolaire</option>
</select>
</td>
<td><button type="button" class="button button-secondary save-structure-type">Enregistrer</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<section class="card coverage-settings-card">
<div class="section-heading">
<div>
@@ -216,151 +43,4 @@
<button id="save-coverage-rules" class="button button-primary" type="button" disabled>Enregistrer les horaires de couverture</button>
</div>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Informations des agents</h2>
<p class="section-subtitle">Consultez la qualification, les coordonnées et le lieu principal de chaque agent. Le bouton Modifier ouvre la fiche complète.</p>
</div>
</div>
<div id="assignment-message" class="message" role="status" aria-live="polite"></div>
<div class="table-scroll">
<table class="planning-table assignment-table agent-information-table">
<thead>
<tr>
<th>Agent</th>
<th>Matricule</th>
<th>Poste</th>
<th>Qualification</th>
<th>Téléphone</th>
<th>Début contrat</th>
<th>Lieu principal</th>
<th>Action</th>
</tr>
</thead>
<tbody id="agent-assignment-body">
<?php foreach ($agents as $agent): ?>
<tr
data-agent-id="<?= (int) $agent['id_agent'] ?>"
data-email="<?= htmlspecialchars((string) ($agent['email'] ?? ''), ENT_QUOTES) ?>"
data-telephone="<?= htmlspecialchars((string) ($agent['telephone'] ?? ''), ENT_QUOTES) ?>"
data-adresse="<?= htmlspecialchars((string) ($agent['adresse'] ?? ''), ENT_QUOTES) ?>"
data-est-diplome="<?= !empty($agent['est_diplome']) ? '1' : '0' ?>"
data-diplome-libelle="<?= htmlspecialchars((string) ($agent['diplome_libelle'] ?? ''), ENT_QUOTES) ?>"
data-date-debut-contrat="<?= htmlspecialchars((string) ($agent['date_debut_contrat'] ?? ''), ENT_QUOTES) ?>"
data-date-fin-contrat="<?= htmlspecialchars((string) ($agent['date_fin_contrat'] ?? ''), ENT_QUOTES) ?>"
data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>"
data-droit-enveloppe-formation="<?= !isset($agent['droit_enveloppe_formation']) || !empty($agent['droit_enveloppe_formation']) ? '1' : '0' ?>"
data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>"
data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
>
<td><strong class="agent-row-name"><?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom']) ?></strong></td>
<td><?= htmlspecialchars($agent['matricule']) ?></td>
<td class="agent-row-post"><?= htmlspecialchars((string) (($agent['poste_libelle'] ?? '') ?: 'Non renseigné')) ?></td>
<td class="agent-row-qualification">
<?= !empty($agent['est_diplome'])
? 'Diplômé' . (!empty($agent['diplome_libelle']) ? ' — ' . htmlspecialchars($agent['diplome_libelle']) : '')
: 'Non diplômé' ?>
</td>
<td class="agent-row-phone"><?= htmlspecialchars((string) (($agent['telephone'] ?? '') ?: 'Non renseigné')) ?></td>
<td class="agent-row-contract-start"><?= htmlspecialchars((string) (($agent['date_debut_contrat'] ?? '') ?: 'Non renseignée')) ?></td>
<td class="agent-row-structure"><?= htmlspecialchars((string) (($agent['structure_nom'] ?? '') ?: 'Sans lieu principal')) ?></td>
<td><button type="button" class="button button-secondary edit-agent-profile">Modifier</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
</section>
<div id="agent-profile-modal" class="modal-backdrop" hidden>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="agent-profile-modal-title">
<div class="section-heading modal-heading">
<div>
<h2 id="agent-profile-modal-title">Modifier les informations de l’agent</h2>
<p id="agent-profile-modal-subtitle" class="section-subtitle"></p>
</div>
<button id="close-agent-profile-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
</div>
<form id="agent-profile-form" class="admin-form">
<input id="edit-agent-profile-id" type="hidden">
<div class="admin-form-grid">
<label class="field">
<span>Poste PTA <strong>*</strong></span>
<select id="edit-agent-profile-post" required>
<option value="">Choisir un poste</option>
<?php foreach ($posts as $post): ?>
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Type de contrat <strong>*</strong></span>
<select id="edit-agent-profile-contract-type" required>
<option value="PERMANENT">Permanent</option>
<option value="TEMPORAIRE">Temporaire</option>
</select>
</label>
<label class="field">
<span>E-mail</span>
<input id="edit-agent-profile-email" type="email" maxlength="255">
</label>
<label class="field">
<span>Téléphone</span>
<input id="edit-agent-profile-phone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
</label>
<label class="field">
<span>Qualification</span>
<select id="edit-agent-profile-qualified">
<option value="0">Non diplômé</option>
<option value="1">Diplômé</option>
</select>
</label>
<label class="field">
<span>Diplôme</span>
<input id="edit-agent-profile-diploma" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…">
</label>
<label class="field admin-field-wide">
<span>Adresse personnelle</span>
<textarea id="edit-agent-profile-address" maxlength="255" rows="3"></textarea>
</label>
<label class="field">
<span>Date de début du contrat <strong>*</strong></span>
<input id="edit-agent-profile-contract-start" type="date" required>
</label>
<label class="field">
<span>Date de fin du contrat</span>
<input id="edit-agent-profile-contract-end" type="date">
</label>
<label class="check-field admin-field-wide">
<input id="edit-agent-profile-training-right" type="checkbox">
<span>Droit à l’enveloppe de formation de 14 h</span>
</label>
<label class="field admin-field-wide">
<span>Observations PTA</span>
<textarea id="edit-agent-profile-pta-comment" maxlength="1000" rows="3"></textarea>
</label>
<label class="field admin-field-wide">
<span>Lieu d’affectation principal</span>
<select id="edit-agent-profile-structure">
<option value="">Sans lieu principal</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>">
<?= htmlspecialchars($structure['nom']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
</div>
<div id="agent-profile-message" class="message" role="status" aria-live="polite"></div>
<div class="admin-form-actions modal-actions">
<button id="cancel-agent-profile" type="button" class="button button-secondary">Annuler</button>
<button type="submit" class="button button-primary">Enregistrer les informations</button>
</div>
</form>
</section>
</div>

View File

@@ -55,7 +55,7 @@
<span class="dashboard-tile-label">Lieux à paramétrer</span>
<strong class="dashboard-tile-value" id="dashboard-setup-count">—</strong>
<small class="dashboard-tile-detail" id="dashboard-setup-detail">Sans horaires de couverture attendus</small>
<span class="dashboard-tile-cta">Ouvrir l’administration</span>
<span class="dashboard-tile-cta">Ouvrir l'administration</span>
</a>
</div>
@@ -68,7 +68,7 @@
</div>
<a class="button button-secondary" href="pending.php">Tout voir</a>
</div>
<div id="dashboard-pending-empty" class="empty-state" hidden>Aucun planning n’attend de validation.</div>
<div id="dashboard-pending-empty" class="empty-state" hidden>Aucun planning n'attend de validation.</div>
<ul class="dashboard-list" id="dashboard-pending-list"></ul>
</section>
@@ -154,9 +154,9 @@
<strong>Ouvrir un PTA détaillé</strong>
<small>Souhaits, contraintes et répartition.</small>
</a>
<a class="dashboard-shortcut" href="administration.php">
<strong>Gérer les référentiels</strong>
<small>Agents, lieux, postes, couverture.</small>
<a class="dashboard-shortcut" href="manage_agents.php">
<strong>Gérer les agents</strong>
<small>Créer, corriger une fiche, changer un rattachement.</small>
</a>
</div>
</section>

229
app/Views/partials/dsi.php Normal file
View File

@@ -0,0 +1,229 @@
<?php
/**
* Espace DSI : comptes d'accès, jetons et journal d'activité.
* Module JS : `dsi`.
*
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
* posés ici sont les points d'ancrage du module JS : les renommer casse le
* module sans aucune erreur visible.
*
* Aucune donnée n'est rendue côté PHP : la page est entièrement alimentée par
* api/dsi_comptes.php et api/dsi_journal.php. C'est ce qui permet de rafraîchir
* le journal sans recharger l'écran, et d'éviter que le HTML servi porte la
* moindre information sur les comptes.
*/
?>
<section class="page-content dsi-page">
<div id="dsi-installation-warning" class="message message-error dsi-installation-warning" role="alert" hidden>
Le compte DSI utilise encore le jeton d'installation, publié en clair dans la migration SQL :
<strong>régénérez-le maintenant</strong>, il donne un accès complet à cette page.
</div>
<!--
Panneau des jetons fraîchement produits. C'est la SEULE occasion de les
lire : la base n'en conserve qu'une empreinte. Il reste ouvert tant que
la DSI ne l'a pas explicitement refermé, pour laisser le temps de les
recopier ou de les imprimer.
-->
<section id="dsi-token-panel" class="card dsi-token-panel" hidden>
<div class="section-heading">
<div>
<h2>Jetons à transmettre</h2>
<p class="section-subtitle">Ces jetons ne seront plus jamais affichés : la base n'en garde qu'une empreinte. Copiez-les, transmettez-les à leurs titulaires, puis refermez ce panneau.</p>
</div>
<div class="button-group">
<button id="dsi-token-copy" class="button button-secondary" type="button">Tout copier</button>
<button id="dsi-token-close" class="button button-secondary" type="button">Fermer</button>
</div>
</div>
<div id="dsi-token-list" class="dsi-token-list"></div>
<div id="dsi-token-message" class="message" role="status" aria-live="polite"></div>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Comptes d'accès</h2>
<p class="section-subtitle">Un compte par utilisateur : agents, responsables de lieu, administrateurs du Service Enfance et DSI. Chacun se connecte avec son jeton, il n’y a aucun mot de passe dans PTA.</p>
</div>
<button id="dsi-generate-missing" class="button button-secondary" type="button">
Générer les jetons manquants <span id="dsi-missing-count" class="nav-count-badge" hidden>0</span>
</button>
</div>
<form id="dsi-account-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Type de compte</span>
<select id="dsi-account-role" required>
<option value="SERVICE_ENFANCE">Service Enfance (administrateur)</option>
<option value="DSI">DSI</option>
<option value="AGENT">Agent</option>
<option value="RESPONSABLE_STRUCTURE">Responsable de structure</option>
</select>
</label>
<label class="field" id="dsi-account-agent-field" hidden>
<span>Agent titulaire</span>
<select id="dsi-account-agent"><option value="">Choisir un agent sans compte</option></select>
</label>
<label class="field" id="dsi-account-structure-field" hidden>
<span>Lieu titulaire</span>
<select id="dsi-account-structure"><option value="">Choisir un lieu sans compte</option></select>
</label>
<label class="field" id="dsi-account-label-field">
<span>Nom du titulaire</span>
<input id="dsi-account-label" type="text" maxlength="180" placeholder="Ex. Service Enfance — Camille Roux">
</label>
<label class="field admin-field-wide">
<span>Commentaire (facultatif)</span>
<input id="dsi-account-comment" type="text" maxlength="255" placeholder="Ex. remplacement congé maternité, jusqu'en septembre">
</label>
</div>
<p id="dsi-account-hint" class="hint"></p>
<div class="admin-form-actions">
<div id="dsi-account-message" class="message" role="status" aria-live="polite"></div>
<button class="button button-primary" type="submit">Créer le compte et générer son jeton</button>
</div>
</form>
<div class="selection-grid overview-filters">
<label class="field">
<span>Type</span>
<select id="dsi-filter-role">
<option value="">Tous les types</option>
<option value="DSI">DSI</option>
<option value="SERVICE_ENFANCE">Service Enfance</option>
<option value="RESPONSABLE_STRUCTURE">Responsable de structure</option>
<option value="AGENT">Agent</option>
</select>
</label>
<label class="field">
<span>État</span>
<select id="dsi-filter-state">
<option value="">Tous</option>
<option value="1">Actifs</option>
<option value="0">Désactivés</option>
</select>
</label>
<label class="field">
<span>Rechercher</span>
<input id="dsi-filter-search" type="search" maxlength="80" placeholder="Nom, matricule ou début de jeton">
</label>
<div class="overview-filter-actions">
<button id="dsi-accounts-refresh" class="button button-secondary filter-button" type="button">Actualiser</button>
</div>
</div>
<div id="dsi-accounts-message" class="message" role="status" aria-live="polite"></div>
<div class="table-scroll">
<table class="planning-table dsi-accounts-table">
<thead>
<tr>
<th>Titulaire</th>
<th>Type</th>
<th>Jeton</th>
<th>Dernière connexion</th>
<th>État</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="dsi-accounts-body">
<tr><td colspan="6">Chargement des comptes…</td></tr>
</tbody>
</table>
</div>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Journal d'activité</h2>
<p class="section-subtitle">Connexions, écritures en base et refus d'accès. Les lectures ne sont pas tracées : elles noieraient le journal sans rien apprendre.</p>
</div>
</div>
<div id="dsi-journal-stats" class="summary-grid compact-summary"></div>
<div class="selection-grid overview-filters dsi-journal-filters">
<label class="field">
<span>Catégorie</span>
<select id="dsi-journal-category">
<option value="">Toutes</option>
<option value="CONNEXION">Connexions</option>
<option value="ECRITURE">Écritures</option>
<option value="ACCES_REFUSE">Refus d'accès</option>
<option value="ADMINISTRATION">Administration</option>
<option value="SYSTEME">Système</option>
</select>
</label>
<label class="field">
<span>Résultat</span>
<select id="dsi-journal-result">
<option value="">Tous</option>
<option value="SUCCES">Succès</option>
<option value="REFUS">Refus</option>
<option value="ERREUR">Erreur</option>
</select>
</label>
<label class="field">
<span>Rôle</span>
<select id="dsi-journal-role">
<option value="">Tous</option>
<option value="DSI">DSI</option>
<option value="SERVICE_ENFANCE">Service Enfance</option>
<option value="RESPONSABLE_STRUCTURE">Responsable de structure</option>
<option value="AGENT">Agent</option>
</select>
</label>
<label class="field">
<span>Du</span>
<input id="dsi-journal-from" type="date">
</label>
<label class="field">
<span>Au</span>
<input id="dsi-journal-to" type="date">
</label>
<label class="field">
<span>Rechercher</span>
<input id="dsi-journal-search" type="search" maxlength="80" placeholder="Acteur, action, adresse IP">
</label>
<div class="overview-filter-actions">
<button id="dsi-journal-refresh" class="button button-secondary filter-button" type="button">Actualiser</button>
</div>
</div>
<div id="dsi-journal-message" class="message" role="status" aria-live="polite"></div>
<div class="table-scroll">
<table class="planning-table dsi-journal-table">
<thead>
<tr>
<th>Date</th>
<th>Catégorie</th>
<th>Acteur</th>
<th>Action</th>
<th>Détail</th>
<th>Origine</th>
</tr>
</thead>
<tbody id="dsi-journal-body">
<tr><td colspan="6">Chargement du journal…</td></tr>
</tbody>
</table>
</div>
<div class="dsi-pagination">
<button id="dsi-journal-previous" class="button button-secondary" type="button" disabled>Page précédente</button>
<span id="dsi-journal-page-label">Page 1</span>
<button id="dsi-journal-next" class="button button-secondary" type="button" disabled>Page suivante</button>
</div>
<form id="dsi-purge-form" class="inline-form dsi-purge-form">
<label class="field">
<span>Purger les entrées de plus de</span>
<input id="dsi-purge-days" type="number" min="30" max="3650" value="365" required>
</label>
<span class="hint">jours (30 minimum : une purge trop courte effacerait les traces d'un incident avant qu'il soit constaté).</span>
<button class="button button-danger" type="submit">Purger</button>
</form>
</section>
</section>

View File

@@ -19,16 +19,16 @@ $roleActuel = (string) ($accessProfile['role'] ?? '');
* droit, plutôt que de masquer les étapes inaccessibles.
*/
$sommaire = [
'faq-parcours' => 'Le parcours en un coup d’œil',
'faq-roles' => 'Qui fait quoi : les trois rôles',
'faq-lieux' => '1. Ajouter un lieu d’affectation',
'faq-parcours' => 'Le parcours en un coup d\'œil',
'faq-roles' => 'Qui fait quoi : les quatre rôles',
'faq-lieux' => '1. Ajouter un lieu d\'affectation',
'faq-agents' => '2. Ajouter un agent',
'faq-couverture' => '3. Définir les horaires d’ouverture',
'faq-couverture' => '3. Définir les horaires d\'ouverture',
'faq-vacances' => '4. Renseigner les vacances scolaires',
'faq-saisie' => '5. Saisir un planning pour un agent',
'faq-validation' => '6. Contrôler et valider un planning',
'faq-equipes' => '7. Composer les équipes',
'faq-suivi' => '8. Suivre l’année et éditer les PDF',
'faq-suivi' => '8. Suivre l\'année et éditer les PDF',
'faq-quota' => 'Comprendre le quota annuel et les motifs',
'faq-questions' => 'Questions fréquentes',
];
@@ -73,8 +73,8 @@ $sommaire = [
<article class="card faq-section" id="faq-roles">
<div class="section-heading">
<div>
<h2>Qui fait quoi : les trois rôles</h2>
<p class="section-subtitle">Votre rôle détermine les onglets visibles et les actions autorisées. Il se choisit à l’ouverture de l’application et se change à tout moment par le bouton « Changer de rôle », en haut à droite.</p>
<h2>Qui fait quoi : les quatre rôles</h2>
<p class="section-subtitle">Votre rôle détermine les onglets visibles et les actions autorisées. Il ne se choisit pas : il est porté par le jeton d’accès que la DSI vous a remis, et que vous saisissez à l’ouverture de l’application. Le bouton « Se déconnecter », en haut à droite, referme la session.</p>
</div>
</div>
<div class="table-scroll">
@@ -99,24 +99,31 @@ $sommaire = [
</tr>
<tr<?= $roleActuel === 'SERVICE_ENFANCE' ? ' class="faq-row-current"' : '' ?>>
<td><strong>Service Enfance</strong></td>
<td>Tous les lieux, tous les agents, tous les écrans.</td>
<td>Tous les lieux, tous les agents, tous les écrans métier.</td>
<td>Paramétrer, saisir, composer les équipes, <strong>valider</strong> et rouvrir les plannings.</td>
</tr>
<tr<?= $roleActuel === 'DSI' ? ' class="faq-row-current"' : '' ?>>
<td><strong>DSI</strong></td>
<td>Uniquement les comptes d’accès et le journal d’activité. Aucun planning, aucun agent.</td>
<td>Créer, désactiver et régénérer les jetons ; consulter les connexions, les écritures et les refus d’accès.</td>
</tr>
</tbody>
</table>
</div>
<p class="hint">Un planning validé n’est plus modifiable par un responsable de structure : il faut demander au Service Enfance de le rouvrir, ce qui le repasse en brouillon.</p>
<p class="hint"><strong>Comptes Service Enfance et DSI :</strong> le jeton ne suffit pas. Un mot de passe, que vous choisissez à votre première connexion et que personne d’autre ne connaît, vous est demandé ensuite. Le bouton « Mot de passe », en haut à droite, permet d’en changer ; en cas d’oubli, la DSI l’efface et vous en choisissez un nouveau.</p>
<p class="hint"><strong>Votre jeton vaut votre signature :</strong> toutes vos écritures sont journalisées à votre nom. Il ne se prête pas. Jeton perdu ou divulgué, la DSI en génère un nouveau, ce qui rend l’ancien inutilisable sur-le-champ.</p>
</article>
<article class="card faq-section" id="faq-lieux">
<div class="section-heading">
<div>
<h2>1. Ajouter un lieu d’affectation</h2>
<p class="section-subtitle">Onglet <strong>Administration</strong> — réservé au Service Enfance. Un lieu doit exister avant qu’un agent puisse y être rattaché ou planifié.</p>
<p class="section-subtitle">Groupe <strong>Management</strong>, onglet <strong>Lieux d’affectation</strong> — réservé au Service Enfance. Un lieu doit exister avant qu’un agent puisse y être rattaché ou planifié.</p>
</div>
</div>
<ol class="faq-steps">
<li>Ouvrez <strong>Administration</strong>, encadré « Ajouter un lieu d’affectation ».</li>
<li>Ouvrez <strong>Management → Lieux d’affectation</strong>, encadré « Ajouter un lieu d’affectation ».</li>
<li>Saisissez le <strong>code</strong> : un identifiant court et stable, sans espace, par exemple <code>CRECHE_MATUSALEME</code>. Il ne s’affiche que dans les tableaux techniques.</li>
<li>Saisissez le <strong>nom</strong> tel qu’il doit apparaître partout ailleurs, par exemple « Crèche Matusalème ».</li>
<li>Choisissez le <strong>type d’affectation</strong> : <em>Périscolaire</em> (autour du temps scolaire) ou <em>Extrascolaire</em> (mercredis et vacances). Ce choix appartient au lieu, pas à l’agent.</li>
@@ -132,7 +139,7 @@ $sommaire = [
<div class="section-heading">
<div>
<h2>2. Ajouter un agent</h2>
<p class="section-subtitle">Onglet <strong>Administration</strong> — réservé au Service Enfance. La fiche agent détermine son quota annuel et les motifs qu’on pourra lui affecter.</p>
<p class="section-subtitle">Groupe <strong>Management</strong>, onglet <strong>Agents</strong> — réservé au Service Enfance. La fiche agent détermine son quota annuel et les motifs qu’on pourra lui affecter.</p>
</div>
</div>
<ol class="faq-steps">
@@ -147,7 +154,8 @@ $sommaire = [
<li><strong>Observations utiles à la construction du PTA</strong> : disponibilités particulières, organisation connue… Ces notes se retrouvent sur l’écran « PTA détaillé ».</li>
<li>Cliquez sur <strong>Ajouter l’agent</strong>.</li>
</ol>
<p class="hint">Pour corriger une fiche existante, utilisez le tableau « Informations des agents » en bas de la page : le bouton <strong>Modifier</strong> ouvre la fiche complète, lieu principal compris.</p>
<p class="hint"><strong>Dates de contrat :</strong> elles bornent la planification. Aucune affectation — pas même un congé — ne peut être posée avant la date de début ou après la date de fin. Un contrat sans date de fin est considéré comme à durée indéterminée. Raccourcir un contrat est refusé tant qu’il reste des affectations au-delà : supprimez-les d’abord.</p>
<p class="hint">Pour corriger une fiche existante, utilisez le tableau « Informations des agents » du même onglet : le bouton <strong>Modifier</strong> ouvre la fiche complète, nom, prénom et lieu principal compris. Seuls le <strong>matricule</strong> et la <strong>quotité de travail</strong> restent hors d’atteinte : le premier sert de clé avec les outils RH, la seconde détermine le quota déjà consommé de l’année.</p>
</article>
<article class="card faq-section" id="faq-couverture">
@@ -388,7 +396,7 @@ $sommaire = [
</div>
<dl class="faq-qa">
<dt>Je ne vois pas l’onglet dont on me parle.</dt>
<dd>Les onglets dépendent du rôle sélectionné. Vérifiez le badge en haut à droite et, si nécessaire, utilisez « Changer de rôle ». Si l’onglet reste absent, votre rôle n’y a pas droit.</dd>
<dd>Les onglets dépendent du rôle porté par votre jeton. Vérifiez le badge en haut à droite : si l’onglet attendu n’apparaît pas, votre rôle n’y a pas droit, et c’est la DSI qui délivre un autre accès.</dd>
<dt>Impossible de choisir un agent dans l’écran de saisie.</dt>
<dd>Il faut d’abord choisir un lieu d’affectation : la liste des agents en dépend. La semaine ne se débloque qu’une fois l’agent choisi.</dd>

View File

@@ -0,0 +1,278 @@
<?php
/**
* Management — Agents : création, liste et fiche complète des agents.
* Module JS : `administration`.
*
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
* posés ici sont les points d'ancrage du module JS : les renommer casse le
* module sans aucune erreur visible.
*
* V7.14 : ce contenu vivait dans le partiel « administration », qui mêlait les
* agents, les lieux et les horaires de couverture. Chaque objet a désormais son
* onglet, sous le groupe « Management » de la barre de navigation.
*
* L'ordre est celui du travail réel : on crée un agent, puis on consulte ou
* corrige sa fiche dans le tableau qui suit.
*/
?>
<section id="manage-agents-view" class="app-view page-content">
<section class="card admin-card">
<div class="section-heading">
<div>
<h2>Ajouter un agent</h2>
<p class="section-subtitle">L’agent reçoit un lieu principal à sa création, mais peut être intégré aussi bien aux équipes périscolaires qu’extrascolaires et travailler ponctuellement sur d’autres lieux.</p>
</div>
</div>
<form id="create-agent-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Matricule <strong>*</strong></span>
<input id="new-agent-matricule" name="matricule" type="text" maxlength="50" required>
</label>
<label class="field">
<span>Poste PTA <strong>*</strong></span>
<select id="new-agent-post" name="id_poste" required>
<option value="">Choisir un poste</option>
<?php foreach ($posts as $post): ?>
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Type de contrat <strong>*</strong></span>
<select id="new-agent-contract-type" required>
<option value="PERMANENT">Permanent — soumis au PTA</option>
<option value="TEMPORAIRE">Temporaire — hors périmètre PTA</option>
</select>
</label>
<label class="field">
<span>Lieu d’affectation principal <strong>*</strong></span>
<select id="new-agent-structure" name="structure_id" required>
<option value="">Choisir un lieu d’affectation</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>">
<?= htmlspecialchars($structure['nom']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Nom <strong>*</strong></span>
<input id="new-agent-name" name="nom" type="text" maxlength="100" required>
</label>
<label class="field">
<span>Prénom <strong>*</strong></span>
<input id="new-agent-firstname" name="prenom" type="text" maxlength="100" required>
</label>
<label class="field">
<span>E-mail</span>
<input id="new-agent-email" name="email" type="email" maxlength="255" placeholder="prenom.nom@exemple.fr">
</label>
<label class="field">
<span>Téléphone</span>
<input id="new-agent-phone" name="telephone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
</label>
<label class="field">
<span>Qualification</span>
<select id="new-agent-qualified" name="est_diplome">
<option value="0">Non diplômé</option>
<option value="1">Diplômé</option>
</select>
</label>
<label class="field">
<span>Diplôme</span>
<input id="new-agent-diploma" name="diplome_libelle" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…" disabled>
</label>
<label class="field admin-field-wide">
<span>Adresse personnelle</span>
<textarea id="new-agent-address" name="adresse" maxlength="255" rows="2" placeholder="Adresse de l’agent"></textarea>
</label>
<label class="field">
<span>Quotité de travail <strong>*</strong></span>
<div class="input-suffix">
<input id="new-agent-rate" name="quotite_travail" type="number" min="1" max="100" step="0.01" value="100" required>
<span>%</span>
</div>
</label>
<label class="field">
<span>Date de début du contrat <strong>*</strong></span>
<input id="new-agent-contract-start" name="date_debut_contrat" type="date" value="<?= date('Y-m-d') ?>" required>
</label>
<label class="field">
<span>Date de fin du contrat</span>
<input id="new-agent-contract-end" name="date_fin_contrat" type="date">
</label>
<label class="check-field admin-field-wide">
<input id="new-agent-training-right" type="checkbox" checked>
<span>Droit à l’enveloppe de formation de 14 h. Décoché, le motif « Formation » ne pourra pas être affecté à cet agent lors de la saisie des horaires.</span>
</label>
<label class="field admin-field-wide">
<span>Observations utiles à la construction du PTA</span>
<textarea id="new-agent-pta-comment" maxlength="1000" rows="3" placeholder="Disponibilités particulières, organisation connue…"></textarea>
</label>
</div>
<p class="hint">Le quota PTA est suivi sur l’année civile, du 1er janvier au 31 décembre. À 100 %, la référence est de 1 607 h. La date de début du contrat reste une information RH mais ne décale pas l’année de référence.</p>
<div class="admin-form-actions">
<div id="create-agent-message" class="message" role="status" aria-live="polite"></div>
<button class="button button-primary" type="submit">Ajouter l’agent</button>
</div>
</form>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Informations des agents</h2>
<p class="section-subtitle">Consultez la qualification, les coordonnées et le lieu principal de chaque agent. Le bouton Modifier ouvre la fiche complète.</p>
</div>
</div>
<div id="assignment-message" class="message" role="status" aria-live="polite"></div>
<div class="table-scroll">
<table class="planning-table assignment-table agent-information-table">
<thead>
<tr>
<th>Agent</th>
<th>Matricule</th>
<th>Poste</th>
<th>Qualification</th>
<th>Téléphone</th>
<th>Début contrat</th>
<th>Lieu principal</th>
<th>Action</th>
</tr>
</thead>
<tbody id="agent-assignment-body">
<?php foreach ($agents as $agent): ?>
<tr
data-agent-id="<?= (int) $agent['id_agent'] ?>"
data-prenom="<?= htmlspecialchars((string) $agent['prenom'], ENT_QUOTES) ?>"
data-nom="<?= htmlspecialchars((string) $agent['nom'], ENT_QUOTES) ?>"
data-email="<?= htmlspecialchars((string) ($agent['email'] ?? ''), ENT_QUOTES) ?>"
data-telephone="<?= htmlspecialchars((string) ($agent['telephone'] ?? ''), ENT_QUOTES) ?>"
data-adresse="<?= htmlspecialchars((string) ($agent['adresse'] ?? ''), ENT_QUOTES) ?>"
data-est-diplome="<?= !empty($agent['est_diplome']) ? '1' : '0' ?>"
data-diplome-libelle="<?= htmlspecialchars((string) ($agent['diplome_libelle'] ?? ''), ENT_QUOTES) ?>"
data-date-debut-contrat="<?= htmlspecialchars((string) ($agent['date_debut_contrat'] ?? ''), ENT_QUOTES) ?>"
data-date-fin-contrat="<?= htmlspecialchars((string) ($agent['date_fin_contrat'] ?? ''), ENT_QUOTES) ?>"
data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>"
data-droit-enveloppe-formation="<?= !isset($agent['droit_enveloppe_formation']) || !empty($agent['droit_enveloppe_formation']) ? '1' : '0' ?>"
data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>"
data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
>
<td><strong class="agent-row-name"><?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom']) ?></strong></td>
<td><?= htmlspecialchars($agent['matricule']) ?></td>
<td class="agent-row-post"><?= htmlspecialchars((string) (($agent['poste_libelle'] ?? '') ?: 'Non renseigné')) ?></td>
<td class="agent-row-qualification">
<?= !empty($agent['est_diplome'])
? 'Diplômé' . (!empty($agent['diplome_libelle']) ? ' — ' . htmlspecialchars($agent['diplome_libelle']) : '')
: 'Non diplômé' ?>
</td>
<td class="agent-row-phone"><?= htmlspecialchars((string) (($agent['telephone'] ?? '') ?: 'Non renseigné')) ?></td>
<td class="agent-row-contract-start"><?= htmlspecialchars((string) (($agent['date_debut_contrat'] ?? '') ?: 'Non renseignée')) ?></td>
<td class="agent-row-structure"><?= htmlspecialchars((string) (($agent['structure_nom'] ?? '') ?: 'Sans lieu principal')) ?></td>
<td><button type="button" class="button button-secondary edit-agent-profile">Modifier</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
</section>
<div id="agent-profile-modal" class="modal-backdrop" hidden>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="agent-profile-modal-title">
<div class="section-heading modal-heading">
<div>
<h2 id="agent-profile-modal-title">Modifier les informations de l’agent</h2>
<p id="agent-profile-modal-subtitle" class="section-subtitle"></p>
</div>
<button id="close-agent-profile-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
</div>
<form id="agent-profile-form" class="admin-form">
<input id="edit-agent-profile-id" type="hidden">
<div class="admin-form-grid">
<?php /* Le matricule reste absent : c'est la clé de rapprochement avec les outils RH. */ ?>
<label class="field">
<span>Prénom <strong>*</strong></span>
<input id="edit-agent-profile-firstname" type="text" maxlength="100" required>
</label>
<label class="field">
<span>Nom <strong>*</strong></span>
<input id="edit-agent-profile-lastname" type="text" maxlength="100" required>
</label>
<label class="field">
<span>Poste PTA <strong>*</strong></span>
<select id="edit-agent-profile-post" required>
<option value="">Choisir un poste</option>
<?php foreach ($posts as $post): ?>
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>Type de contrat <strong>*</strong></span>
<select id="edit-agent-profile-contract-type" required>
<option value="PERMANENT">Permanent</option>
<option value="TEMPORAIRE">Temporaire</option>
</select>
</label>
<label class="field">
<span>E-mail</span>
<input id="edit-agent-profile-email" type="email" maxlength="255">
</label>
<label class="field">
<span>Téléphone</span>
<input id="edit-agent-profile-phone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
</label>
<label class="field">
<span>Qualification</span>
<select id="edit-agent-profile-qualified">
<option value="0">Non diplômé</option>
<option value="1">Diplômé</option>
</select>
</label>
<label class="field">
<span>Diplôme</span>
<input id="edit-agent-profile-diploma" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…">
</label>
<label class="field admin-field-wide">
<span>Adresse personnelle</span>
<textarea id="edit-agent-profile-address" maxlength="255" rows="3"></textarea>
</label>
<label class="field">
<span>Date de début du contrat <strong>*</strong></span>
<input id="edit-agent-profile-contract-start" type="date" required>
</label>
<label class="field">
<span>Date de fin du contrat</span>
<input id="edit-agent-profile-contract-end" type="date">
</label>
<label class="check-field admin-field-wide">
<input id="edit-agent-profile-training-right" type="checkbox">
<span>Droit à l’enveloppe de formation de 14 h</span>
</label>
<label class="field admin-field-wide">
<span>Observations PTA</span>
<textarea id="edit-agent-profile-pta-comment" maxlength="1000" rows="3"></textarea>
</label>
<label class="field admin-field-wide">
<span>Lieu d’affectation principal</span>
<select id="edit-agent-profile-structure">
<option value="">Sans lieu principal</option>
<?php foreach ($structures as $structure): ?>
<option value="<?= (int) $structure['id_structure'] ?>">
<?= htmlspecialchars($structure['nom']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
</div>
<div id="agent-profile-message" class="message" role="status" aria-live="polite"></div>
<div class="admin-form-actions modal-actions">
<button id="cancel-agent-profile" type="button" class="button button-secondary">Annuler</button>
<button type="submit" class="button button-primary">Enregistrer les informations</button>
</div>
</form>
</section>
</div>

View File

@@ -0,0 +1,87 @@
<?php
/**
* Management — Lieux d'affectation : création et paramètres des lieux.
* Module JS : `administration`.
*
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
* posés ici sont les points d'ancrage du module JS : les renommer casse le
* module sans aucune erreur visible.
*
* Les HORAIRES DE COUVERTURE d'un lieu ne sont pas ici mais dans l'onglet
* « Administration » : ils ne décrivent pas le lieu, ils décrivent une exigence
* de présence, contrôlée par l'onglet « Couverture ».
*/
?>
<section id="manage-structures-view" class="app-view page-content">
<section class="card admin-card">
<div class="section-heading">
<div>
<h2>Ajouter un lieu d’affectation</h2>
<p class="section-subtitle">Chaque lieu possède son propre type : périscolaire ou extrascolaire.</p>
</div>
</div>
<form id="create-structure-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Code <strong>*</strong></span>
<input id="new-structure-code" name="code" type="text" maxlength="50" placeholder="Ex. CRECHE_MATUSALEME" required>
</label>
<label class="field">
<span>Nom <strong>*</strong></span>
<input id="new-structure-name" name="nom" type="text" maxlength="150" placeholder="Ex. Crèche Matusalème" required>
</label>
<label class="field">
<span>Type d’affectation <strong>*</strong></span>
<select id="new-structure-type" name="type_affectation" required>
<option value="PERISCOLAIRE">Périscolaire</option>
<option value="EXTRASCOLAIRE">Extrascolaire</option>
</select>
</label>
<label class="field admin-field-wide">
<span>Adresse</span>
<input id="new-structure-address" name="adresse" type="text" maxlength="255" placeholder="Adresse du lieu">
</label>
</div>
<div class="admin-form-actions">
<div id="create-structure-message" class="message" role="status" aria-live="polite"></div>
<button class="button button-primary" type="submit">Ajouter le lieu</button>
</div>
</form>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Paramètres des lieux d’affectation</h2>
<p class="section-subtitle">Le type d’affectation est défini au niveau du lieu : par exemple « Crèche Matusalème — Périscolaire ».</p>
</div>
</div>
<div id="structure-settings-message" class="message" role="status" aria-live="polite"></div>
<div class="table-scroll">
<table class="planning-table assignment-table">
<thead>
<tr>
<th>Lieu</th>
<th>Code</th>
<th>Type d’affectation</th>
<th>Action</th>
</tr>
</thead>
<tbody id="structure-settings-body">
<?php foreach ($structures as $structure): ?>
<tr data-structure-id="<?= (int) $structure['id_structure'] ?>">
<td><strong><?= htmlspecialchars($structure['nom']) ?></strong></td>
<td><?= htmlspecialchars($structure['code']) ?></td>
<td>
<select class="structure-type-select" aria-label="Type d’affectation de <?= htmlspecialchars($structure['nom']) ?>">
<option value="PERISCOLAIRE" <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'PERISCOLAIRE' ? 'selected' : '' ?>>Périscolaire</option>
<option value="EXTRASCOLAIRE" <?= ($structure['type_affectation'] ?? '') === 'EXTRASCOLAIRE' ? 'selected' : '' ?>>Extrascolaire</option>
</select>
</td>
<td><button type="button" class="button button-secondary save-structure-type">Enregistrer</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>

View File

@@ -15,6 +15,7 @@ declare(strict_types=1);
* @return PDO Connexion partagée par les contrôleurs et les modèles.
*/
use App\Core\Journal;
use App\Core\Session;
use App\Models\SchemaModel;
@@ -23,4 +24,9 @@ Session::start();
$pdo = require dirname(__DIR__) . '/db.php';
(new SchemaModel($pdo))->assertCurrent();
// Le journal d'activité (V7.11) reçoit ici sa connexion, une fois pour toute la
// requête : les gardes statiques (Csrf) et les contrôleurs peuvent alors tracer
// une action sans avoir à transporter le PDO.
Journal::useConnection($pdo);
return $pdo;

View File

@@ -270,6 +270,8 @@
*/
function setAgentRowData(row, agent) {
row.dataset.agentId = String(Number(agent.id_agent));
row.dataset.prenom = agent.prenom || '';
row.dataset.nom = agent.nom || '';
row.dataset.email = agent.email || '';
row.dataset.telephone = agent.telephone || '';
row.dataset.adresse = agent.adresse || '';
@@ -323,6 +325,8 @@
document.querySelector('#edit-agent-profile-id').value = row.dataset.agentId || '';
document.querySelector('#agent-profile-modal-subtitle').textContent = row.querySelector('.agent-row-name')?.textContent?.trim() || '';
document.querySelector('#edit-agent-profile-firstname').value = row.dataset.prenom || '';
document.querySelector('#edit-agent-profile-lastname').value = row.dataset.nom || '';
document.querySelector('#edit-agent-profile-email').value = row.dataset.email || '';
document.querySelector('#edit-agent-profile-phone').value = row.dataset.telephone || '';
document.querySelector('#edit-agent-profile-address').value = row.dataset.adresse || '';
@@ -376,6 +380,8 @@
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
agent_id: agentId,
prenom: document.querySelector('#edit-agent-profile-firstname').value,
nom: document.querySelector('#edit-agent-profile-lastname').value,
email: document.querySelector('#edit-agent-profile-email').value,
telephone: document.querySelector('#edit-agent-profile-phone').value,
adresse: document.querySelector('#edit-agent-profile-address').value,

529
assets/js/dsi.js Normal file
View File

@@ -0,0 +1,529 @@
/**
* Espace DSI (`PTA.modules.dsi`), écran dsi.php — V7.11.
*
* Deux blocs indépendants dans une seule page :
* - les COMPTES D'ACCÈS : création, régénération de jeton, activation,
* suppression, et génération en lot pour les identités qui n'ont pas encore
* de compte ;
* - le JOURNAL D'ACTIVITÉ : recherche filtrée et paginée, statistiques de tête,
* purge des entrées anciennes.
*
* RÈGLE CENTRALE DE L'ÉCRAN : un jeton n'existe en clair que dans la réponse qui
* vient de le produire. Il est donc affiché dans un panneau dédié, qui reste
* ouvert tant que la DSI ne l'a pas refermé, et n'est jamais rechargé depuis le
* serveur — la base n'en garde qu'une empreinte. Toute la mise en garde de
* l'interface découle de là.
*/
(() => {
'use strict';
const PTA = window.PTA;
// core.js absent : la page reste lisible, simplement non interactive.
if (!PTA) return;
const { readJsonResponse, apiErrorMessage, escapeHtml } = PTA.utils;
const el = (id) => document.getElementById(id);
/** Page courante du journal, seul état conservé par le module. */
let pageJournal = 1;
const LIBELLES_ROLE = {
DSI: 'DSI',
SERVICE_ENFANCE: 'Service Enfance',
RESPONSABLE_STRUCTURE: 'Responsable de structure',
AGENT: 'Agent',
};
const LIBELLES_CATEGORIE = {
CONNEXION: 'Connexion',
ECRITURE: 'Écriture',
ACCES_REFUSE: 'Refus d’accès',
ADMINISTRATION: 'Administration',
SYSTEME: 'Système',
};
/** Message dans un des bandeaux de la page. */
function message(id, texte, type = 'info') {
const boite = el(id);
if (!boite) return;
boite.className = `message message-${type}`;
boite.textContent = texte;
}
/**
* Appel d'API mutualisé : lit le JSON et transforme toute réponse non-OK en
* exception. Toutes les actions de la page passent par ici.
*/
async function api(url, options = {}) {
const reponse = await fetch(url, options);
const donnees = await readJsonResponse(reponse);
if (!reponse.ok) throw new Error(apiErrorMessage(donnees, 'Opération impossible.'));
return donnees;
}
/** Raccourci des appels mutants : POST JSON avec le jeton CSRF du corps. */
function poster(url, charge) {
return api(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, ...charge }),
});
}
/** « 2026-08-20 09:41:07.482 » → « 20/08/2026 09:41 ». */
function formatDateHeure(valeur) {
if (!valeur) return '—';
const [date, heure = ''] = String(valeur).split(' ');
const [annee, mois, jour] = date.split('-');
return `${jour}/${mois}/${annee} ${heure.slice(0, 5)}`;
}
// ------------------------------------------------------------------ jetons
/**
* Affiche les jetons produits par la dernière action.
*
* @param {Array<{libelle: string, role: string, jeton: string}>} jetons
*/
function afficherJetons(jetons) {
const panneau = el('dsi-token-panel');
const liste = el('dsi-token-list');
if (!panneau || !liste || !jetons.length) return;
liste.innerHTML = jetons.map((entree) => `
<div class="dsi-token-row">
<div class="dsi-token-identity">
<strong>${escapeHtml(entree.libelle)}</strong>
<small>${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role)}</small>
</div>
<code class="dsi-token-value">${escapeHtml(entree.jeton)}</code>
</div>
`).join('');
panneau.hidden = false;
message('dsi-token-message', `${jetons.length} jeton(s) à transmettre. Ils ne seront plus affichés après fermeture. Les comptes Service Enfance et DSI choisiront en plus un mot de passe à leur première connexion.`, 'warning');
panneau.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/**
* Copie les jetons affichés dans le presse-papiers.
*
* L'API Clipboard n'existe QUE dans un contexte sécurisé : sur une
* installation en http:// sur le réseau de la collectivité, elle est
* absente. D'où le repli par zone de texte masquée, puis, en dernier
* ressort, l'invitation à sélectionner à la main.
*/
async function copierJetons() {
const lignes = Array.from(document.querySelectorAll('#dsi-token-list .dsi-token-row')).map((ligne) => {
const identite = ligne.querySelector('strong')?.textContent || '';
const jeton = ligne.querySelector('.dsi-token-value')?.textContent || '';
return `${identite}\t${jeton}`;
});
if (!lignes.length) return;
const texte = lignes.join('\n');
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(texte);
} else {
const zone = document.createElement('textarea');
zone.value = texte;
zone.setAttribute('readonly', 'readonly');
zone.className = 'dsi-clipboard-relay';
document.body.appendChild(zone);
zone.select();
document.execCommand('copy');
zone.remove();
}
message('dsi-token-message', `${lignes.length} jeton(s) copié(s) dans le presse-papiers.`, 'success');
} catch (erreur) {
message('dsi-token-message', 'Copie impossible sur ce navigateur : sélectionnez les jetons à la main.', 'error');
}
}
/** Referme le panneau des jetons ; leur contenu est perdu pour de bon. */
function fermerJetons() {
const panneau = el('dsi-token-panel');
if (!panneau) return;
panneau.hidden = true;
el('dsi-token-list').innerHTML = '';
message('dsi-token-message', '');
}
// ----------------------------------------------------------------- comptes
/** Une ligne du tableau des comptes. */
function ligneCompte(compte) {
const identite = [
`<strong>${escapeHtml(compte.identite_libelle)}</strong>`,
compte.matricule ? `<br><small>Matricule ${escapeHtml(compte.matricule)}</small>` : '',
compte.commentaire ? `<br><small>${escapeHtml(compte.commentaire)}</small>` : '',
].join('');
const etats = [];
if (!compte.actif) etats.push('<span class="dsi-state dsi-state-off">Désactivé</span>');
else if (!compte.identite_active) etats.push('<span class="dsi-state dsi-state-off">Identité inactive</span>');
else etats.push('<span class="dsi-state dsi-state-on">Actif</span>');
if (compte.jeton_installation) etats.push('<span class="dsi-state dsi-state-alert">Jeton d’installation</span>');
// Second facteur (V7.12) : seuls les comptes Service Enfance et DSI en
// portent un. « À définir » n'est pas une anomalie — c'est l'état normal
// d'un compte neuf ou fraîchement réinitialisé.
if (compte.mot_de_passe_requis) {
etats.push(compte.mot_de_passe_defini
? '<span class="dsi-state dsi-state-on">Mot de passe défini</span>'
: '<span class="dsi-state dsi-state-wait">Mot de passe à définir</span>');
}
const connexions = compte.nombre_connexions > 0
? `<br><small>${compte.nombre_connexions} connexion(s)</small>`
: '';
return `
<tr data-compte-id="${Number(compte.id_compte)}">
<td>${identite}</td>
<td>${escapeHtml(LIBELLES_ROLE[compte.role] || compte.role)}</td>
<td><code>${escapeHtml(compte.jeton_prefixe)}…</code></td>
<td>${formatDateHeure(compte.date_derniere_connexion)}${connexions}</td>
<td>${etats.join(' ')}</td>
<td class="dsi-actions-cell">
<button type="button" class="button button-secondary dsi-regenerate">Régénérer</button>
<button type="button" class="button button-secondary dsi-toggle">${compte.actif ? 'Désactiver' : 'Réactiver'}</button>
${compte.mot_de_passe_requis && compte.mot_de_passe_defini
? '<button type="button" class="button button-secondary dsi-reset-password">Effacer le mot de passe</button>'
: ''}
<button type="button" class="button button-danger dsi-delete">Supprimer</button>
<button type="button" class="button button-secondary dsi-see-journal">Journal</button>
</td>
</tr>`;
}
/**
* Remplit les listes déroulantes de création à partir des identités SANS
* compte : proposer un agent qui en a déjà un ne mènerait qu'à un refus du
* serveur (un seul compte par identité).
*/
function remplirIdentites(manquants) {
const agents = manquants.filter((identite) => identite.role === 'AGENT');
const lieux = manquants.filter((identite) => identite.role === 'RESPONSABLE_STRUCTURE');
const selectAgent = el('dsi-account-agent');
const selectLieu = el('dsi-account-structure');
if (selectAgent) {
selectAgent.innerHTML = '<option value="">Choisir un agent sans compte</option>'
+ agents.map((agent) => `<option value="${Number(agent.id_agent)}">${escapeHtml(agent.libelle)}</option>`).join('');
}
if (selectLieu) {
selectLieu.innerHTML = '<option value="">Choisir un lieu sans compte</option>'
+ lieux.map((lieu) => `<option value="${Number(lieu.id_structure)}">${escapeHtml(lieu.libelle)}</option>`).join('');
}
const compteur = el('dsi-missing-count');
if (compteur) {
compteur.textContent = String(manquants.length);
compteur.hidden = manquants.length === 0;
}
const bouton = el('dsi-generate-missing');
if (bouton) bouton.disabled = manquants.length === 0;
}
/** Recharge la liste des comptes selon les filtres courants. */
async function chargerComptes() {
const parametres = new URLSearchParams();
const role = el('dsi-filter-role')?.value || '';
const etat = el('dsi-filter-state')?.value || '';
const recherche = el('dsi-filter-search')?.value.trim() || '';
if (role) parametres.set('role', role);
if (etat !== '') parametres.set('actif', etat);
if (recherche) parametres.set('q', recherche);
message('dsi-accounts-message', 'Chargement des comptes…');
try {
const donnees = await api(`api/dsi_comptes.php?${parametres.toString()}`);
const comptes = donnees.comptes || [];
el('dsi-accounts-body').innerHTML = comptes.length
? comptes.map(ligneCompte).join('')
: '<tr><td colspan="6">Aucun compte ne correspond à ces filtres.</td></tr>';
remplirIdentites(donnees.manquants || []);
const alerte = el('dsi-installation-warning');
if (alerte) alerte.hidden = !donnees.jeton_installation_actif;
message('dsi-accounts-message', `${comptes.length} compte(s) affiché(s).`, 'success');
} catch (erreur) {
message('dsi-accounts-message', erreur.message, 'error');
}
}
/**
* Ce qu'il faut renseigner change entièrement selon le type de compte :
* l'écran l'explique plutôt que de laisser deviner pourquoi un champ
* apparaît ou disparaît.
*/
const AIDES_ROLE = {
SERVICE_ENFANCE: 'Compte d’administration métier : accès à tous les lieux, à tous les agents et à la validation des plannings. Il n’est rattaché à aucun agent en base — indiquez simplement le nom de son titulaire. Son titulaire choisira un mot de passe à sa première connexion, en plus du jeton.',
DSI: 'Compte d’administration technique : comptes d’accès et journal uniquement, aucun accès aux plannings. Prévoyez-en au moins deux, pour ne pas dépendre d’un seul jeton. Son titulaire choisira un mot de passe à sa première connexion, en plus du jeton.',
AGENT: 'Compte en lecture seule sur son propre planning. Seuls les agents actifs qui n’ont pas encore de compte sont proposés.',
RESPONSABLE_STRUCTURE: 'Compte d’édition des brouillons d’un seul lieu. Seuls les lieux actifs qui n’ont pas encore de compte sont proposés.',
};
/** Affiche les seuls champs d'identité utiles au type de compte choisi. */
function ajusterFormulaireCompte() {
const role = el('dsi-account-role')?.value || '';
const champAgent = el('dsi-account-agent-field');
const champLieu = el('dsi-account-structure-field');
const champLibelle = el('dsi-account-label-field');
if (!champAgent || !champLieu || !champLibelle) return;
champAgent.hidden = role !== 'AGENT';
champLieu.hidden = role !== 'RESPONSABLE_STRUCTURE';
// Service Enfance et DSI ne pointent vers aucune identité en base : leur
// seul identifiant lisible est le libellé saisi ici.
champLibelle.hidden = role === 'AGENT' || role === 'RESPONSABLE_STRUCTURE';
el('dsi-account-agent').required = role === 'AGENT';
el('dsi-account-structure').required = role === 'RESPONSABLE_STRUCTURE';
el('dsi-account-label').required = !champLibelle.hidden;
const aide = el('dsi-account-hint');
if (aide) aide.textContent = AIDES_ROLE[role] || '';
}
/** Crée un compte, puis affiche son jeton — unique occasion de le lire. */
async function creerCompte(evenement) {
evenement.preventDefault();
const role = el('dsi-account-role').value;
try {
const donnees = await poster('api/dsi_compte_creer.php', {
role,
agent_id: role === 'AGENT' ? Number(el('dsi-account-agent').value) : null,
structure_id: role === 'RESPONSABLE_STRUCTURE' ? Number(el('dsi-account-structure').value) : null,
libelle: el('dsi-account-label').value,
commentaire: el('dsi-account-comment').value,
});
message('dsi-account-message', donnees.message, 'success');
el('dsi-account-label').value = '';
el('dsi-account-comment').value = '';
afficherJetons([{
libelle: donnees.compte?.identite_libelle || '',
role: donnees.compte?.role || role,
jeton: donnees.jeton,
}]);
await chargerComptes();
} catch (erreur) {
message('dsi-account-message', erreur.message, 'error');
}
}
/** Régénération, activation, suppression : délégation sur le tableau. */
async function agirSurCompte(evenement) {
const ligne = evenement.target.closest('tr[data-compte-id]');
if (!ligne) return;
const compteId = Number(ligne.dataset.compteId);
const titulaire = ligne.querySelector('strong')?.textContent || 'ce compte';
try {
if (evenement.target.closest('.dsi-regenerate')) {
if (!window.confirm(`Régénérer le jeton de ${titulaire} ? L’ancien cessera immédiatement de fonctionner.`)) return;
const donnees = await poster('api/dsi_compte_jeton.php', { compte_id: compteId });
afficherJetons([{
libelle: donnees.compte?.identite_libelle || titulaire,
role: donnees.compte?.role || '',
jeton: donnees.jeton,
}]);
message('dsi-accounts-message', donnees.message, 'success');
await chargerComptes();
return;
}
if (evenement.target.closest('.dsi-reset-password')) {
if (!window.confirm(`Effacer le mot de passe de ${titulaire} ? Son jeton reste valable, et il en choisira un nouveau à sa prochaine connexion.`)) return;
const donnees = await poster('api/dsi_compte_mot_de_passe.php', { compte_id: compteId });
message('dsi-accounts-message', donnees.message, 'success');
await chargerComptes();
return;
}
const bascule = evenement.target.closest('.dsi-toggle');
if (bascule) {
const action = bascule.textContent.trim() === 'Désactiver' ? 'DESACTIVER' : 'ACTIVER';
const donnees = await poster('api/dsi_compte_statut.php', { compte_id: compteId, action });
message('dsi-accounts-message', donnees.message, 'success');
await chargerComptes();
return;
}
if (evenement.target.closest('.dsi-delete')) {
if (!window.confirm(`Supprimer définitivement le compte de ${titulaire} ? Ses traces dans le journal seront conservées.`)) return;
const donnees = await poster('api/dsi_compte_statut.php', { compte_id: compteId, action: 'SUPPRIMER' });
message('dsi-accounts-message', donnees.message, 'success');
await chargerComptes();
return;
}
if (evenement.target.closest('.dsi-see-journal')) {
// Filtre le journal sur ce compte, sans quitter la page.
el('dsi-journal-search').value = '';
pageJournal = 1;
await chargerJournal({ id_compte: compteId });
el('dsi-journal-body')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} catch (erreur) {
message('dsi-accounts-message', erreur.message, 'error');
}
}
/** Équipe d'un coup toutes les identités actives sans compte. */
async function genererManquants() {
const attendus = Number(el('dsi-missing-count')?.textContent || 0);
if (!attendus) return;
if (!window.confirm(`Générer ${attendus} jeton(s) ? Ils ne seront affichés qu’une seule fois, tout de suite après.`)) return;
try {
const donnees = await poster('api/dsi_comptes_manquants.php', {});
message('dsi-accounts-message', donnees.message, 'success');
afficherJetons(donnees.jetons || []);
await chargerComptes();
} catch (erreur) {
message('dsi-accounts-message', erreur.message, 'error');
}
}
// ----------------------------------------------------------------- journal
/** Une ligne du journal. */
function ligneJournal(entree) {
const detail = [
entree.description ? escapeHtml(entree.description) : '',
entree.cible_table
? `<br><small>${escapeHtml(entree.cible_table)}${entree.cible_id ? ` #${Number(entree.cible_id)}` : ''}</small>`
: '',
entree.contexte
? `<br><small class="dsi-journal-context">${escapeHtml(JSON.stringify(entree.contexte))}</small>`
: '',
].join('');
const classeResultat = entree.resultat === 'SUCCES' ? '' : ` dsi-journal-${entree.resultat.toLowerCase()}`;
return `
<tr class="dsi-journal-row${classeResultat}">
<td>${formatDateHeure(entree.date_evenement)}</td>
<td>${escapeHtml(LIBELLES_CATEGORIE[entree.categorie] || entree.categorie)}</td>
<td>${escapeHtml(entree.acteur || 'Anonyme')}<br><small>${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role || '—')}</small></td>
<td><code>${escapeHtml(entree.action)}</code></td>
<td>${detail}</td>
<td>${escapeHtml(entree.adresse_ip || '—')}</td>
</tr>`;
}
/** Bandeau de chiffres au-dessus du journal. */
function afficherStatistiques(stats) {
const cible = el('dsi-journal-stats');
if (!cible || !stats) return;
const cartes = [
['Entrées au total', stats.total],
['Connexions (24 h)', stats.connexions_24h],
['Refus (24 h)', stats.refus_24h],
['Écritures (7 j)', stats.sept_jours?.ECRITURE || 0],
];
cible.innerHTML = cartes.map(([libelle, valeur]) => `
<article class="summary-card"><span>${escapeHtml(libelle)}</span><strong>${Number(valeur || 0)}</strong></article>
`).join('');
}
/**
* Recharge le journal.
*
* @param {object} supplements Filtres imposés par le code (ex. id_compte
* depuis le bouton « Journal » d'un compte), qui
* s'ajoutent à ceux saisis dans l'écran.
*/
async function chargerJournal(supplements = {}) {
const parametres = new URLSearchParams();
const champs = {
categorie: el('dsi-journal-category')?.value || '',
resultat: el('dsi-journal-result')?.value || '',
role: el('dsi-journal-role')?.value || '',
du: el('dsi-journal-from')?.value || '',
au: el('dsi-journal-to')?.value || '',
q: el('dsi-journal-search')?.value.trim() || '',
};
Object.entries({ ...champs, ...supplements }).forEach(([cle, valeur]) => {
if (valeur !== '' && valeur !== null && valeur !== undefined) parametres.set(cle, String(valeur));
});
parametres.set('page', String(pageJournal));
message('dsi-journal-message', 'Chargement du journal…');
try {
const donnees = await api(`api/dsi_journal.php?${parametres.toString()}`);
const entrees = donnees.entrees || [];
el('dsi-journal-body').innerHTML = entrees.length
? entrees.map(ligneJournal).join('')
: '<tr><td colspan="6">Aucune entrée pour ces filtres.</td></tr>';
afficherStatistiques(donnees.statistiques);
// Le serveur peut ramener la page demandée à la dernière page réelle.
pageJournal = Number(donnees.page || 1);
el('dsi-journal-page-label').textContent = `Page ${pageJournal} sur ${Number(donnees.pages || 1)} — ${Number(donnees.total || 0)} entrée(s)`;
el('dsi-journal-previous').disabled = pageJournal <= 1;
el('dsi-journal-next').disabled = pageJournal >= Number(donnees.pages || 1);
message('dsi-journal-message', '');
} catch (erreur) {
message('dsi-journal-message', erreur.message, 'error');
}
}
/** Purge des entrées anciennes, confirmée puis journalisée côté serveur. */
async function purgerJournal(evenement) {
evenement.preventDefault();
const jours = Number(el('dsi-purge-days').value);
if (!window.confirm(`Supprimer définitivement les entrées de plus de ${jours} jours ?`)) return;
try {
const donnees = await poster('api/dsi_journal_purge.php', { jours });
message('dsi-journal-message', donnees.message, 'success');
pageJournal = 1;
await chargerJournal();
} catch (erreur) {
message('dsi-journal-message', erreur.message, 'error');
}
}
function init() {
const params = window.PTA_PAGE_CONFIG?.params || {};
el('dsi-account-role')?.addEventListener('change', ajusterFormulaireCompte);
el('dsi-account-form')?.addEventListener('submit', creerCompte);
el('dsi-accounts-body')?.addEventListener('click', agirSurCompte);
el('dsi-accounts-refresh')?.addEventListener('click', chargerComptes);
el('dsi-filter-role')?.addEventListener('change', chargerComptes);
el('dsi-filter-state')?.addEventListener('change', chargerComptes);
el('dsi-filter-search')?.addEventListener('change', chargerComptes);
el('dsi-generate-missing')?.addEventListener('click', genererManquants);
el('dsi-token-copy')?.addEventListener('click', copierJetons);
el('dsi-token-close')?.addEventListener('click', fermerJetons);
['dsi-journal-category', 'dsi-journal-result', 'dsi-journal-role', 'dsi-journal-from', 'dsi-journal-to', 'dsi-journal-search']
.forEach((identifiant) => el(identifiant)?.addEventListener('change', () => {
pageJournal = 1;
chargerJournal();
}));
el('dsi-journal-refresh')?.addEventListener('click', () => chargerJournal());
el('dsi-journal-previous')?.addEventListener('click', () => {
pageJournal = Math.max(1, pageJournal - 1);
chargerJournal();
});
el('dsi-journal-next')?.addEventListener('click', () => {
pageJournal += 1;
chargerJournal();
});
el('dsi-purge-form')?.addEventListener('submit', purgerJournal);
ajusterFormulaireCompte();
chargerComptes();
// Lien profond « journal de ce compte » venu d'une autre page.
chargerJournal(params.compteId ? { id_compte: params.compteId } : {});
}
PTA.modules.dsi = { init, chargerComptes, chargerJournal };
})();

View File

@@ -78,6 +78,8 @@ h2 { margin-bottom: 18px; font-size: 1.15rem; }
font-size: 0.9rem;
}
.field[hidden] { display: none; }
.field strong { color: #c62828; }
select, input {
@@ -2369,6 +2371,9 @@ textarea:disabled {
font-weight: 900;
line-height: 1;
}
/* Sans cette règle, la pastille affiche « 0 » en permanence : `display: inline-flex`
ci-dessus l'emporte sur le `[hidden]` de la feuille du navigateur. */
.nav-count-badge[hidden] { display: none; }
.role-access-warning,
.role-page-notice { margin: 0 0 16px; }
.entry-locked {
@@ -2417,53 +2422,6 @@ textarea:disabled {
font-size: 0.82rem;
}
.role-selection-form { display: grid; gap: 18px; }
.role-choice-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.role-choice-card { position: relative; cursor: pointer; }
.role-choice-card input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.role-choice-content {
display: grid;
align-content: start;
min-height: 150px;
padding: 20px;
border: 2px solid #dce4e8;
border-radius: 16px;
background: #fff;
box-shadow: 0 5px 15px rgba(25, 54, 69, 0.05);
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, background 0.15s ease;
}
.role-choice-card:hover .role-choice-content {
transform: translateY(-2px);
border-color: #9cbccd;
box-shadow: 0 10px 24px rgba(25, 54, 69, 0.1);
}
.role-choice-card input:focus-visible + .role-choice-content {
outline: 3px solid rgba(25, 113, 164, 0.25);
outline-offset: 3px;
}
.role-choice-card input:checked + .role-choice-content {
border-color: #1971a4;
background: #f0f8fc;
box-shadow: 0 0 0 4px rgba(25, 113, 164, 0.1);
}
.role-choice-content strong { margin-bottom: 9px; color: #163d55; font-size: 1.03rem; }
.role-choice-content small { color: #5e6f79; font-size: 0.82rem; line-height: 1.5; }
.role-context-card {
padding: 18px;
border: 1px solid #d8e2e7;
border-radius: 14px;
background: #f8fafb;
}
.role-context-card[hidden] { display: none; }
.role-form-actions { display: flex; justify-content: center; }
.role-form-actions .button { min-width: 210px; }
.role-security-note {
@@ -3162,3 +3120,102 @@ textarea:disabled {
.template-range { grid-template-columns: 1fr 1fr auto; }
.template-range-separator { display: none; }
}
/* ---------------------------------------------------------------------------
Management (V7.14) — titres de regroupement par objet
--------------------------------------------------------------------------- */
.management-group-title {
margin: 8px 0 0;
padding-bottom: 8px;
border-bottom: 2px solid #dce4e8;
color: #163d55;
font-size: 1.25rem;
}
.management-group-title:not(:first-of-type) { margin-top: 18px; }
/* ---------------------------------------------------------------------------
Connexion par jeton (V7.11) — écran role.php
--------------------------------------------------------------------------- */
.role-token-form { max-width: 520px; margin: 0 auto; }
.role-token-input {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 1.05rem;
letter-spacing: 0.08em;
text-align: center;
text-transform: uppercase;
}
.role-resume-link { margin-left: 10px; }
.role-step-back { margin: 0; text-align: center; }
/* ---------------------------------------------------------------------------
Espace DSI (V7.11) — comptes d'accès et journal d'activité
--------------------------------------------------------------------------- */
.dsi-page { display: grid; gap: 18px; }
.dsi-installation-warning { margin: 0; }
/* Panneau des jetons en clair : volontairement contrasté, il ne doit pas passer
inaperçu — son contenu n'est affiché qu'une fois. */
.dsi-token-panel { border: 2px solid #d9911e; background: #fffdf7; }
.dsi-token-list { display: grid; gap: 8px; margin: 14px 0; }
.dsi-token-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid #ead9b6;
border-radius: 10px;
background: #fff;
}
.dsi-token-identity { display: grid; }
.dsi-token-identity small { color: #66737d; font-size: 0.78rem; }
.dsi-token-value {
padding: 6px 10px;
border-radius: 8px;
background: #f3f7f9;
color: #163d55;
font-size: 1rem;
letter-spacing: 0.06em;
user-select: all;
}
/* Zone technique du repli de copie : présente dans le DOM, jamais visible. */
.dsi-clipboard-relay { position: fixed; top: -1000px; left: -1000px; opacity: 0; }
.dsi-accounts-table code, .dsi-journal-table code { font-size: 0.82rem; color: #31556a; }
.dsi-actions-cell { display: flex; flex-wrap: wrap; gap: 6px; }
.dsi-actions-cell .button { padding: 6px 10px; font-size: 0.78rem; }
.dsi-state {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 0.75rem;
white-space: nowrap;
}
.dsi-state-on { background: #e6f4ec; color: #1d6b45; }
.dsi-state-off { background: #f0f2f4; color: #66737d; }
.dsi-state-alert { background: #fdeceb; color: #a12a20; }
.dsi-state-wait { background: #fff4e2; color: #8a5a12; }
.dsi-journal-filters { align-items: end; }
.dsi-journal-table td { vertical-align: top; font-size: 0.85rem; }
.dsi-journal-table small { color: #66737d; }
.dsi-journal-context { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.72rem; }
.dsi-journal-refus { background: #fff6f5; }
.dsi-journal-erreur { background: #fdeceb; }
.dsi-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 14px;
color: #66737d;
font-size: 0.85rem;
}
.dsi-purge-form { align-items: end; margin-top: 18px; }
.dsi-purge-form .hint { margin: 0; }
@media (max-width: 850px) {
.dsi-token-row { flex-direction: column; align-items: flex-start; }
.dsi-pagination { flex-direction: column; }
}

View File

@@ -0,0 +1,275 @@
-- ============================================================================
-- PTA V7.11 — Accès par jeton, comptes gérés par la DSI et journal d'activité
-- ============================================================================
-- Trois apports, indissociables :
-- 1. un compte d'accès par utilisateur (compte_acces), identifié par un JETON
-- unique dont la base ne conserve qu'une empreinte SHA-256 ;
-- 2. un quatrième rôle DSI, seul habilité à créer, régénérer et désactiver
-- ces comptes, et à lire le journal ;
-- 3. un journal d'activité (journal_activite) alimenté par le code PHP.
--
-- POURQUOI « v7_9e » ALORS QUE C'EST LA V7.11 : les migrations sont jouées dans
-- l'ORDRE ALPHABÉTIQUE (docker/db/apply-migrations.sh). Un fichier nommé
-- « migration_v7_11_… » passerait AVANT « migration_v7_8_… », donc avant la
-- création des tables de droits qu'il doit étendre. Le préfixe suit donc la
-- dernière migration en date (v7_9d) plutôt que le numéro de version
-- fonctionnelle. Voir CLAUDE.md, section « Commandes ».
--
-- Prérequis : migration_v7_8_securite_acces.sql (tables acces_role_*).
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 1. LE RÔLE DSI ENTRE DANS LA MATRICE DES DROITS
-- ----------------------------------------------------------------------------
-- Un MODIFY d'ENUM est idempotent : rejouer la migration ne change rien.
ALTER TABLE acces_role_page
MODIFY COLUMN role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL;
ALTER TABLE acces_role_permission
MODIFY COLUMN role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL;
-- Les vues sont recréées après l'ALTER : leurs métadonnées de colonnes sont
-- figées à la définition, un ENUM élargi ne s'y propage pas tout seul.
CREATE OR REPLACE VIEW v_acces_role_page AS
SELECT role, page, ordre_affichage
FROM acces_role_page;
CREATE OR REPLACE VIEW v_acces_role_permission AS
SELECT role, permission, autorise
FROM acces_role_permission;
-- La DSI n'a qu'une page : la sienne. Elle n'accède NI à la saisie, NI aux
-- référentiels métier — c'est une administration technique, pas fonctionnelle.
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
('DSI', 'dsi', 10)
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
-- Deux permissions nouvelles, refusées explicitement aux trois rôles métier :
-- une ligne à FALSE documente le refus, là où une ligne absente le laisse
-- deviner (une permission inconnue vaut de toute façon « refusé »).
INSERT INTO acces_role_permission (role, permission, autorise) VALUES
('DSI', 'can_manage_access', TRUE),
('DSI', 'can_read_journal', TRUE),
('DSI', 'read_only', FALSE),
('DSI', 'can_edit_draft', FALSE),
('DSI', 'can_edit_cross_structure', FALSE),
('DSI', 'can_validate', FALSE),
('DSI', 'can_reopen', FALSE),
('DSI', 'can_use_templates', FALSE),
('DSI', 'can_copy_full_week', FALSE),
('DSI', 'can_view_pending', FALSE),
('DSI', 'can_manage_coverage', FALSE),
('DSI', 'can_manage_agents', FALSE),
('DSI', 'can_manage_structures', FALSE),
('DSI', 'can_manage_vacations', FALSE),
('DSI', 'can_manage_teams', FALSE),
('DSI', 'can_manage_pta', FALSE),
('DSI', 'can_forecast_next_year', FALSE),
('AGENT', 'can_manage_access', FALSE),
('AGENT', 'can_read_journal', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_access', FALSE),
('RESPONSABLE_STRUCTURE', 'can_read_journal', FALSE),
('SERVICE_ENFANCE', 'can_manage_access', FALSE),
('SERVICE_ENFANCE', 'can_read_journal', FALSE)
ON DUPLICATE KEY UPDATE autorise = VALUES(autorise);
-- ----------------------------------------------------------------------------
-- 2. COMPTES D'ACCÈS — un jeton par utilisateur, jamais de mot de passe
-- ----------------------------------------------------------------------------
-- Le jeton complet n'est JAMAIS stocké : la base ne garde qu'une empreinte
-- SHA-256 et les huit premiers caractères, qui servent uniquement à reconnaître
-- une ligne dans la liste. Un jeton perdu ne se retrouve pas, il se régénère.
-- Une fuite du dump SQL ne donne donc aucun accès.
--
-- L'empreinte n'est pas salée, et c'est délibéré : le jeton est tiré au sort sur
-- 120 bits par le serveur, il n'est pas devinable par force brute comme le
-- serait un mot de passe choisi par un humain. L'absence de sel est ce qui
-- permet de retrouver le compte en une lecture d'index à la connexion.
--
-- id_agent / id_structure sont UNIQUE tout en étant NULLables : MariaDB tolère
-- autant de NULL que voulu dans un index unique. Un agent ne peut donc avoir
-- qu'un seul compte, un lieu qu'un seul compte responsable, tandis que les
-- comptes SERVICE_ENFANCE et DSI (les deux colonnes à NULL) sont libres en
-- nombre.
CREATE TABLE IF NOT EXISTS compte_acces (
id_compte INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL,
libelle VARCHAR(180) NOT NULL COMMENT 'Titulaire du jeton, tel qu''affiché dans la liste',
id_agent INT UNSIGNED NULL COMMENT 'Obligatoire pour un compte AGENT',
id_structure INT UNSIGNED NULL COMMENT 'Obligatoire pour un compte RESPONSABLE_STRUCTURE',
jeton_empreinte CHAR(64) NOT NULL COMMENT 'SHA-256 hexadécimal du jeton',
jeton_prefixe VARCHAR(12) NOT NULL COMMENT 'Début du jeton, pour le reconnaître sans le révéler',
actif BOOLEAN NOT NULL DEFAULT TRUE,
commentaire VARCHAR(255) NULL,
date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
date_rotation DATETIME NULL COMMENT 'Dernière régénération du jeton',
date_derniere_connexion DATETIME NULL,
nombre_connexions INT UNSIGNED NOT NULL DEFAULT 0,
CONSTRAINT uk_compte_empreinte UNIQUE (jeton_empreinte),
CONSTRAINT uk_compte_agent UNIQUE (id_agent),
CONSTRAINT uk_compte_structure UNIQUE (id_structure),
INDEX idx_compte_role_actif (role, actif),
CONSTRAINT fk_compte_agent FOREIGN KEY (id_agent)
REFERENCES agent (id_agent) ON DELETE CASCADE,
CONSTRAINT fk_compte_structure FOREIGN KEY (id_structure)
REFERENCES structure (id_structure) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ----------------------------------------------------------------------------
-- 3. COMPTE DSI D'INSTALLATION
-- ----------------------------------------------------------------------------
-- Problème de l'œuf et de la poule : les jetons ne sont distribuables que par la
-- DSI, qui a elle-même besoin d'un jeton pour entrer. Cette ligne est la porte
-- d'entrée initiale.
--
-- JETON D'INSTALLATION : PTA-INSTALLATION-DSI
--
-- Il est public — il est écrit ici, dans un fichier versionné. La page DSI
-- affiche un avertissement rouge tant que ce compte porte encore cette
-- empreinte : LE RÉGÉNÉRER EST LA PREMIÈRE ACTION À FAIRE APRÈS L'INSTALLATION.
--
-- L'identifiant 1 est forcé pour que le rejeu de la migration ne recrée pas un
-- second compte d'installation après régénération du jeton (l'empreinte, elle,
-- aura changé). Supprimer la ligne 1 puis rejouer la migration est d'ailleurs la
-- procédure de secours documentée en cas de perte du jeton DSI.
INSERT INTO compte_acces
(id_compte, role, libelle, jeton_empreinte, jeton_prefixe, commentaire)
VALUES
(1, 'DSI', 'Compte DSI d''installation',
'7f61ffc8efed4ed46d581a98286115ad03bb0122ffc70db3398584348aca431d',
'PTA-INSTALL',
'Jeton d''installation public : à régénérer immédiatement.')
ON DUPLICATE KEY UPDATE id_compte = id_compte;
-- ----------------------------------------------------------------------------
-- 4. JOURNAL D'ACTIVITÉ
-- ----------------------------------------------------------------------------
-- Alimenté exclusivement par App\Core\Journal, jamais par des déclencheurs SQL :
-- le journal doit nommer l'UTILISATEUR à l'origine de l'action, information que
-- la base n'a pas (la connexion MariaDB est la même pour tout le monde).
--
-- Sont tracés : les connexions et déconnexions, toutes les écritures en base et
-- tous les refus d'accès. Les lectures ne le sont pas — c'est un choix de
-- volume : elles représenteraient l'essentiel des lignes pour une valeur
-- d'audit très faible.
--
-- L'acteur est FIGÉ au moment de l'action (rôle, libellé) : renommer un agent ou
-- supprimer son compte ne doit pas réécrire l'histoire. C'est aussi pourquoi la
-- clé étrangère est en ON DELETE SET NULL et non en CASCADE.
CREATE TABLE IF NOT EXISTS journal_activite (
id_journal BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
date_evenement DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
categorie ENUM('CONNEXION', 'ECRITURE', 'ACCES_REFUSE', 'ADMINISTRATION', 'SYSTEME') NOT NULL,
action VARCHAR(60) NOT NULL COMMENT 'Code stable de l''action, ex. PLANNING_VALIDE',
resultat ENUM('SUCCES', 'REFUS', 'ERREUR') NOT NULL DEFAULT 'SUCCES',
id_compte INT UNSIGNED NULL,
role VARCHAR(30) NULL COMMENT 'Rôle au moment de l''action, figé',
acteur VARCHAR(180) NULL COMMENT 'Libellé de l''utilisateur au moment de l''action, figé',
cible_table VARCHAR(64) NULL COMMENT 'Table principale touchée par l''écriture',
cible_id INT UNSIGNED NULL,
description VARCHAR(500) NULL,
contexte JSON NULL COMMENT 'Détail structuré, ex. semaine et lieu concernés',
adresse_ip VARCHAR(45) NULL,
agent_utilisateur VARCHAR(255) NULL,
INDEX idx_journal_date (date_evenement),
INDEX idx_journal_categorie (categorie, date_evenement),
INDEX idx_journal_compte (id_compte, date_evenement),
INDEX idx_journal_cible (cible_table, cible_id),
INDEX idx_journal_ip (adresse_ip, date_evenement),
CONSTRAINT fk_journal_compte FOREIGN KEY (id_compte)
REFERENCES compte_acces (id_compte) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ----------------------------------------------------------------------------
-- 5. VUES
-- ----------------------------------------------------------------------------
-- Comme pour la V7.8, le PHP interroge des vues et non les tables : l'identité
-- réelle derrière un compte (nom de l'agent, nom du lieu) et son activité sont
-- résolues en SQL, une seule fois, au même endroit.
CREATE OR REPLACE VIEW v_acces_compte AS
SELECT
c.id_compte,
c.role,
c.libelle,
c.id_agent,
c.id_structure,
c.jeton_empreinte,
c.jeton_prefixe,
c.actif,
c.commentaire,
c.date_creation,
c.date_rotation,
c.date_derniere_connexion,
c.nombre_connexions,
a.matricule,
a.nom AS agent_nom,
a.prenom AS agent_prenom,
s.code AS structure_code,
s.nom AS structure_nom,
-- Libellé affiché : l'identité VIVE pour un agent ou un lieu (un agent
-- renommé doit apparaître sous son nouveau nom), le libellé saisi pour les
-- comptes Service Enfance et DSI, qui ne pointent vers rien.
CASE c.role
WHEN 'AGENT' THEN CONCAT(a.prenom, ' ', a.nom)
WHEN 'RESPONSABLE_STRUCTURE' THEN s.nom
ELSE c.libelle
END AS identite_libelle,
-- Un compte reste utilisable tant que l'identité qu'il porte est active :
-- désactiver un agent coupe son accès sans avoir à toucher à son compte.
CASE c.role
WHEN 'AGENT' THEN COALESCE(a.actif, 0)
WHEN 'RESPONSABLE_STRUCTURE' THEN COALESCE(s.actif, 0)
ELSE 1
END AS identite_active
FROM compte_acces c
LEFT JOIN agent a ON a.id_agent = c.id_agent
LEFT JOIN structure s ON s.id_structure = c.id_structure;
-- Identités actives encore dépourvues de compte : alimente le bouton
-- « générer les jetons manquants » de la page DSI, qui permet d'équiper d'un
-- coup tous les agents et tous les lieux lors de la mise en service.
CREATE OR REPLACE VIEW v_acces_compte_manquant AS
SELECT
'AGENT' AS role,
a.id_agent AS id_agent,
CAST(NULL AS UNSIGNED) AS id_structure,
CONCAT(a.prenom, ' ', a.nom) AS libelle
FROM agent a
LEFT JOIN compte_acces c ON c.id_agent = a.id_agent
WHERE a.actif = 1 AND c.id_compte IS NULL
UNION ALL
SELECT
'RESPONSABLE_STRUCTURE',
CAST(NULL AS UNSIGNED),
s.id_structure,
s.nom
FROM structure s
LEFT JOIN compte_acces c ON c.id_structure = s.id_structure
WHERE s.actif = 1 AND c.id_compte IS NULL;
-- Journal enrichi du compte encore existant, quand il existe. Le libellé figé
-- (`acteur`) reste la source d'affichage : la jointure ne sert qu'à retrouver le
-- préfixe du jeton et à filtrer par compte.
CREATE OR REPLACE VIEW v_journal_activite AS
SELECT
j.id_journal,
j.date_evenement,
j.categorie,
j.action,
j.resultat,
j.id_compte,
j.role,
j.acteur,
j.cible_table,
j.cible_id,
j.description,
j.contexte,
j.adresse_ip,
j.agent_utilisateur,
c.jeton_prefixe,
c.actif AS compte_actif
FROM journal_activite j
LEFT JOIN compte_acces c ON c.id_compte = j.id_compte;

View File

@@ -0,0 +1,91 @@
-- ============================================================================
-- PTA V7.12 — Mot de passe des comptes privilégiés (Service Enfance et DSI)
-- ============================================================================
-- Le jeton reste le seul facteur pour les agents et les responsables de lieu.
-- Les comptes qui VALIDENT (Service Enfance) et ceux qui DISTRIBUENT LES ACCÈS
-- (DSI) en réclament un second : un mot de passe, connu du seul titulaire.
--
-- Deux facteurs de nature différente : 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 jamais affiché nulle part. Un jeton intercepté ne
-- suffit donc plus à valider un planning ni à créer un compte.
--
-- CONTRAIREMENT AU JETON, le mot de passe est haché avec password_hash()
-- (bcrypt) et non SHA-256 : il est choisi par un humain, donc attaquable par
-- dictionnaire, et c'est précisément ce que le hachage lent rend coûteux. La
-- vérification se fait sur un compte déjà identifié par son jeton, l'absence
-- d'index sur le mot de passe n'a donc aucune importance.
--
-- POURQUOI « v7_9f » : les migrations sont jouées dans l'ORDRE ALPHABÉTIQUE
-- (docker/db/apply-migrations.sh). Le préfixe suit la dernière migration en date
-- (v7_9e), pas le numéro de version fonctionnelle. Voir CLAUDE.md.
--
-- Prérequis : migration_v7_9e_dsi_comptes_journal.sql.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 1. DEUX COLONNES SUR LE COMPTE
-- ----------------------------------------------------------------------------
-- `IF NOT EXISTS` est une extension MariaDB : elle rend l'ALTER idempotent, ce
-- qu'exige le rejeu des migrations.
--
-- Le hachage est NULLABLE, et cet état porte du sens : « mot de passe pas encore
-- défini ». C'est l'état d'un compte fraîchement créé, et c'est aussi ce que
-- produit une réinitialisation par la DSI. Un compte privilégié dans cet état ne
-- peut pas ouvrir de session : la connexion impose d'abord d'en choisir un.
ALTER TABLE compte_acces
ADD COLUMN IF NOT EXISTS mot_de_passe_hash VARCHAR(255) NULL
COMMENT 'password_hash() du mot de passe ; NULL = à définir à la prochaine connexion',
ADD COLUMN IF NOT EXISTS date_mot_de_passe DATETIME NULL
COMMENT 'Dernière définition ou modification du mot de passe par le titulaire';
-- ----------------------------------------------------------------------------
-- 2. LA VUE PORTE LA RÈGLE « QUI DOIT AVOIR UN MOT DE PASSE »
-- ----------------------------------------------------------------------------
-- Comme pour les droits depuis la V7.8, la règle descend en base : le PHP
-- demande à la vue si ce compte réclame un second facteur, il ne rejoue pas la
-- liste des rôles concernés de son côté.
CREATE OR REPLACE VIEW v_acces_compte AS
SELECT
c.id_compte,
c.role,
c.libelle,
c.id_agent,
c.id_structure,
c.jeton_empreinte,
c.jeton_prefixe,
c.actif,
c.commentaire,
c.date_creation,
c.date_rotation,
c.date_derniere_connexion,
c.nombre_connexions,
c.mot_de_passe_hash,
c.date_mot_de_passe,
-- Les deux rôles qui engagent l'application : celui qui valide les plannings
-- de toute la collectivité, et celui qui distribue les accès.
CASE WHEN c.role IN ('SERVICE_ENFANCE', 'DSI') THEN 1 ELSE 0 END AS mot_de_passe_requis,
CASE WHEN c.mot_de_passe_hash IS NULL THEN 0 ELSE 1 END AS mot_de_passe_defini,
a.matricule,
a.nom AS agent_nom,
a.prenom AS agent_prenom,
s.code AS structure_code,
s.nom AS structure_nom,
-- Libellé affiché : l'identité VIVE pour un agent ou un lieu (un agent
-- renommé doit apparaître sous son nouveau nom), le libellé saisi pour les
-- comptes Service Enfance et DSI, qui ne pointent vers rien.
CASE c.role
WHEN 'AGENT' THEN CONCAT(a.prenom, ' ', a.nom)
WHEN 'RESPONSABLE_STRUCTURE' THEN s.nom
ELSE c.libelle
END AS identite_libelle,
-- Un compte reste utilisable tant que l'identité qu'il porte est active :
-- désactiver un agent coupe son accès sans avoir à toucher à son compte.
CASE c.role
WHEN 'AGENT' THEN COALESCE(a.actif, 0)
WHEN 'RESPONSABLE_STRUCTURE' THEN COALESCE(s.actif, 0)
ELSE 1
END AS identite_active
FROM compte_acces c
LEFT JOIN agent a ON a.id_agent = c.id_agent
LEFT JOIN structure s ON s.id_structure = c.id_structure;

View File

@@ -0,0 +1,38 @@
-- ============================================================================
-- PTA V7.14 — Groupe « Management » : onglets Agents et Lieux d'affectation
-- ============================================================================
-- L'écran « Administration » réunissait trois objets sans rapport : le
-- référentiel des agents, celui des lieux, et les horaires de couverture
-- attendus. Les deux premiers deviennent deux ONGLETS distincts, réunis dans un
-- groupe « Management » de la barre de navigation, au même titre que
-- « Piloter » ou « Planifier ». « Administration » ne conserve que les horaires
-- de couverture.
--
-- Le GROUPE lui-même est un simple regroupement d'affichage
-- (app/Views/layouts/header.php) : la base ne connaît que des pages. Ce sont
-- donc deux lignes qu'il faut ici, une par onglet.
--
-- Comme toute page de PTA, celles-ci n'existent que si le droit est en base :
-- sans ces lignes, les pages renvoient vers l'accueil du rôle et les onglets
-- n'apparaissent pas (voir CLAUDE.md, « Rôles et périmètres »).
--
-- La ligne « management » d'une version intermédiaire de cette migration est
-- supprimée : cette page unique n'existe plus, et une page inconnue laissée dans
-- acces_role_page finirait dans le groupe de repêchage « Autres » de la barre de
-- navigation.
--
-- POURQUOI « v7_9g » : les migrations sont jouées dans l'ORDRE ALPHABÉTIQUE
-- (docker/db/apply-migrations.sh). Le préfixe suit la dernière migration en date
-- (v7_9f), pas le numéro de version fonctionnelle.
--
-- Prérequis : migration_v7_8_securite_acces.sql.
-- ============================================================================
DELETE FROM acces_role_page WHERE page = 'management';
-- 95 et 96 : les deux onglets se rangent juste avant « Administration » (100),
-- dont ils reprennent l'essentiel du contenu.
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
('SERVICE_ENFANCE', 'manage_agents', 95),
('SERVICE_ENFANCE', 'manage_structures', 96)
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);

View File

@@ -123,11 +123,13 @@ done
log "Bilan : $applique migration(s) appliquée(s), $ignore ignorée(s)."
# ----------------------------------------------------------------------------
# Contrôle final : les vues de droits doivent exister
# Contrôle final : les vues de droits et d'accès doivent exister
# ----------------------------------------------------------------------------
# Même contrôle que SchemaModel::assertAccessViews() côté PHP. Le faire ici
# évite de laisser démarrer une pile dont la page d'accueil renverrait une
# erreur 500 incompréhensible.
# Même contrôle que SchemaModel::assertAccessViews() et assertJetonViews() côté
# PHP. Le faire ici évite de laisser démarrer une pile dont la page d'accueil
# renverrait une erreur 500 incompréhensible. Les trois dernières vues portent
# les comptes à jeton et le journal d'activité (V7.11) : sans elles, personne ne
# peut se connecter.
manquantes=$(sql -N -B -e "
SELECT GROUP_CONCAT(attendue.nom ORDER BY attendue.nom SEPARATOR ', ')
FROM (
@@ -138,6 +140,9 @@ manquantes=$(sql -N -B -e "
UNION ALL SELECT 'v_acces_agent_par_structure'
UNION ALL SELECT 'v_acces_perimetre_creneau'
UNION ALL SELECT 'v_acces_perimetre_planning'
UNION ALL SELECT 'v_acces_compte'
UNION ALL SELECT 'v_acces_compte_manquant'
UNION ALL SELECT 'v_journal_activite'
) AS attendue
LEFT JOIN information_schema.VIEWS v
ON v.TABLE_SCHEMA = DATABASE()
@@ -146,8 +151,8 @@ manquantes=$(sql -N -B -e "
if [ -n "$manquantes" ] && [ "$manquantes" != "NULL" ]; then
log "ERREUR : vue(s) de contrôle d'accès manquante(s) : $manquantes"
log "La migration v7.8 n'a pas produit le résultat attendu."
log "Les migrations v7.8 et v7.9e n'ont pas produit le résultat attendu."
exit 1
fi
log "Les sept vues de contrôle d'accès sont présentes. Base à jour."
log "Les dix vues de contrôle d'accès sont présentes. Base à jour."

View File

@@ -74,3 +74,41 @@ automatiquement au déploiement par le service `db-migrate` du docker-compose.
## V7.4 — Rôles et droits d’accès
La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`.
## V7.11 — Connexion par jeton, espace DSI et journal d’activité
`role.php` n’est plus un sélecteur de rôle : chaque utilisateur se connecte avec un jeton personnel
distribué par la DSI, dont la base ne conserve qu’une empreinte SHA-256. Un quatrième rôle `DSI`
ouvre `dsi.php` : gestion des comptes d’accès (dont les administrateurs Service Enfance) et lecture
du journal d’activité, qui trace les connexions, les écritures en base et les refus d’accès. La
migration `database/migration_v7_9e_dsi_comptes_journal.sql` est obligatoire ; elle crée aussi le
compte DSI d’installation dont le jeton, public, est à régénérer immédiatement. Voir
`README_V7_11.md`.
## V7.12 — Mot de passe des comptes privilégiés
Les comptes Service Enfance et DSI ajoutent un second facteur à leur jeton : un mot de passe choisi
par leur titulaire à sa première connexion, que la DSI ne peut qu’effacer, jamais lire. Les agents et
les responsables de lieu restent à un seul facteur. Migration
`database/migration_v7_9f_mot_de_passe_admin.sql`. Voir `README_V7_12.md`.
## V7.13 — Nom et prénom modifiables
La fiche agent laisse désormais corriger le nom et le prénom, à côté des autres champs déjà
modifiables. Le matricule et la quotité de travail restent immuables. Un renommage est journalisé
avec l’identité précédente. Aucune migration. Voir `README_V7_13.md`.
## V7.14 — Groupe de navigation « Management »
Les agents et les lieux d’affectation quittent l’onglet « Administration » pour deux onglets
distincts, réunis dans un groupe « Management » de la barre de navigation, au même titre que
« Piloter » ou « Planifier ». « Administration » ne conserve que les horaires de couverture.
Migration `database/migration_v7_9g_management.sql` (droits d’accès aux deux pages). Voir
`README_V7_14.md`.
## V7.15 — Affectations bornées par le contrat
Un agent ne peut plus être planifié avant sa date de début de contrat ni après sa date de fin, quel
que soit le chemin d’écriture (saisie, déplacement, copie de semaine, semaine type). La validation
signale les affectations devenues hors contrat, et raccourcir un contrat sous des affectations
existantes est refusé. Aucune migration. Voir `README_V7_15.md`.

165
docs/README_V7_11.md Normal file
View File

@@ -0,0 +1,165 @@
# PTA V7.11 — Connexion par jeton, espace DSI et journal d'activité
## Le problème traité
Depuis l'origine, `role.php` était un **sélecteur, pas une authentification** : n'importe qui
ouvrant l'application choisissait librement son rôle et son identité, y compris « Service Enfance »,
qui valide les plannings de toute la collectivité. Les READMEs successifs et `CLAUDE.md` la
désignaient comme *la faille ouverte la plus importante du projet*.
Deux conséquences, au-delà du contrôle d'accès lui-même :
- **aucune action n'était imputable** — un planning validé, un agent modifié, un lieu créé ne
portaient le nom de personne ;
- **rien n'était traçable** : pas de journal, donc aucune façon de répondre à « qui a rouvert cette
semaine ? » ou « depuis quand cet accès est-il utilisé ? ».
## Ce qui change
### 1. On entre avec un jeton, plus avec un choix
Chaque utilisateur — agent, responsable de lieu, administrateur du Service Enfance, DSI — reçoit un
**jeton personnel** de la forme `PTA-XXXXXX-XXXXXX-XXXXXX-XXXXXX`, tiré au sort par le serveur sur
120 bits d'entropie, dans un alphabet sans caractères ambigus (ni `I`, ni `O`, ni `0`, ni `1`).
Il n'y a **aucun mot de passe** dans PTA : rien à choisir, rien à renouveler périodiquement, aucune
procédure de réinitialisation à écrire. Un jeton perdu ne se retrouve pas, il se **régénère**, ce qui
révoque l'ancien à la seconde.
Deux façons de le présenter, l'une et l'autre acceptées :
- le **champ de saisie** de `role.php` (POST) — le jeton ne transite alors ni par l'URL, ni par
l'historique du navigateur, ni par les journaux de nginx ;
- le **lien direct** `role.php?token=…` — plus commode à envoyer par courriel, mais le jeton
s'inscrit dans l'historique et les journaux du serveur web, et un lien transféré donne l'accès.
La session ouverte, une redirection immédiate retire le jeton de la barre d'adresse.
Le rôle, l'agent et le lieu ne sont plus déclarés par l'utilisateur : ils sont **lus sur son compte**.
Le reste de la mécanique de droits n'a pas bougé d'une ligne — `Access` interroge les mêmes vues SQL
qu'en V7.8.
### 2. La base ne conserve jamais le jeton
`compte_acces` ne stocke qu'une **empreinte SHA-256** du jeton et ses onze premiers caractères, qui
servent uniquement à reconnaître une ligne dans la liste. Un vol du dump SQL ou un accès à phpMyAdmin
ne donne donc **aucun accès applicatif**.
Conséquence assumée, visible partout dans l'interface : **un jeton n'est affiché qu'une seule fois**,
dans le panneau « Jetons à transmettre » qui suit sa création. Il faut le copier à ce moment-là.
L'empreinte n'est volontairement pas salée, contrairement à ce qu'on ferait d'un mot de passe : un
jeton est tiré par le serveur, pas devinable par dictionnaire, et l'absence de sel est ce qui permet
de retrouver le compte en **une lecture d'index** à la connexion plutôt qu'en comparant à chaque
compte.
### 3. Un quatrième rôle : DSI
`DSI` rejoint `AGENT`, `RESPONSABLE_STRUCTURE` et `SERVICE_ENFANCE` dans `acces_role_page` et
`acces_role_permission`. C'est une administration **technique**, pas fonctionnelle : la DSI ne voit
ni planning, ni quota, ni agent — une seule page lui est ouverte, `dsi.php`, qui contient :
- **les comptes d'accès** : création, régénération de jeton, désactivation, suppression, filtres par
type, par état et par nom ;
- **« Générer les jetons manquants »** : équipe d'un coup tous les agents et tous les lieux actifs
qui n'ont pas encore de compte. C'est l'outil de mise en service — sans lui, personne ne pourrait
entrer le jour du déploiement ;
- **le journal d'activité** : recherche filtrée et paginée, quatre chiffres de tête, purge des
entrées anciennes.
Deux permissions nouvelles, `can_manage_access` et `can_read_journal`, sont accordées à la seule DSI
et **refusées explicitement** aux trois rôles métier.
### 4. Ce qui est journalisé — et ce qui ne l'est pas
`journal_activite` est alimenté par `App\Core\Journal`, jamais par des déclencheurs SQL : la base ne
sait pas qui agit, la connexion MariaDB étant la même pour tout le monde.
| Catégorie | Contenu |
| --- | --- |
| `CONNEXION` | Connexion réussie, jeton refusé, blocage anti-force-brute, déconnexion |
| `ECRITURE` | Toute modification en base, avec la table et l'identifiant visés |
| `ACCES_REFUSE` | Page interdite, rôle insuffisant, périmètre dépassé, jeton CSRF invalide |
| `ADMINISTRATION` | Actions de la DSI sur les comptes et le journal, purge comprise |
| `SYSTEME` | Réservé aux incidents techniques |
**Les lectures ne sont pas tracées** : elles représenteraient l'essentiel du volume pour une valeur
d'audit très faible.
Deux garanties tenues par le code :
- écrire dans le journal **ne peut jamais faire échouer l'action journalisée** — toute erreur
d'insertion est avalée et renvoyée dans `error_log` ;
- la trace est **figée** : rôle et libellé de l'acteur sont copiés au moment de l'action, et la clé
étrangère vers `compte_acces` est en `ON DELETE SET NULL`. Supprimer un compte n'efface pas son
histoire, et renommer un agent ne la réécrit pas.
### 5. Freins et garde-fous
- **Anti-force-brute** : au-delà de 10 jetons refusés en 15 minutes depuis une même adresse IP, plus
aucun jeton n'est même comparé, et le blocage est journalisé. Le compteur, c'est le journal
lui-même — aucune table supplémentaire.
- **Révocation immédiate** : `Access` relit le compte à **chaque requête**. Désactiver un compte,
désactiver l'agent qui le porte ou fermer son lieu referme la session en cours à la requête
suivante, sans attendre son expiration.
- **On ne se coupe pas la branche** : la DSI ne peut ni désactiver ni supprimer le compte avec lequel
elle est connectée, et le modèle refuse de retirer le dernier accès DSI actif.
- **Purge plancher à 30 jours** : une purge plus courte reviendrait à effacer les traces d'un
incident avant même qu'il soit constaté.
## Mise en service
1. Jouer les migrations (`docker compose up -d --build` s'en charge) ; `migration_v7_9e_…` crée les
tables, les vues, le rôle DSI et **un compte DSI d'installation**.
2. Se connecter avec le **jeton d'installation**, écrit en clair dans la migration :
```
PTA-INSTALLATION-DSI
```
Il est **public** — il est versionné dans le dépôt. La page DSI affiche un bandeau rouge tant
qu'il n'a pas été remplacé.
3. **Régénérer immédiatement ce jeton** (bouton « Régénérer » sur la ligne du compte DSI), le copier
et le conserver. Créer au passage un second compte DSI si plusieurs personnes doivent administrer.
4. Cliquer sur **« Générer les jetons manquants »** : un jeton par agent et par lieu actif. Le
panneau les affiche tous, une seule fois — les copier avant de le refermer, puis les distribuer.
5. Créer les comptes **Service Enfance** un par un (ils ne correspondent à aucune identité en base,
leur libellé est saisi à la main).
### Perte du jeton DSI
C'est le seul cas qui ne se résout pas dans l'interface. Procédure de secours, en SQL :
```sql
DELETE FROM compte_acces WHERE id_compte = 1;
```
puis rejouer la migration (`docker compose run --rm db-migrate` après avoir touché le fichier, ou
directement l'`INSERT` de la section 3 de la migration) : le compte d'installation est recréé avec
son jeton public, à régénérer aussitôt.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `database/migration_v7_9e_dsi_comptes_journal.sql` | Rôle DSI, `compte_acces`, `journal_activite`, trois vues, compte d'installation |
| `app/Core/Journal.php` | Écriture des traces ; connexion posée par `app/bootstrap.php` |
| `app/Core/Access.php` | `ROLE_DSI`, `openSession()` (remplace `select()`), relecture du compte, journalisation des refus |
| `app/Models/CompteModel.php` | Jetons : génération, empreinte, authentification, cycle de vie des comptes |
| `app/Models/JournalModel.php` | Lecture paginée, statistiques, compteur d'échecs, purge |
| `app/Controllers/RoleController.php` | Connexion par jeton (champ et lien), déconnexion, frein anti-force-brute |
| `app/Controllers/DsiController.php` | Les sept endpoints de l'espace DSI |
| `dsi.php`, `api/dsi_*.php` | Points d'entrée minces |
| `app/Views/pages/role.php` | Écran de connexion (le sélecteur de rôle a disparu) |
| `app/Views/partials/dsi.php`, `assets/js/dsi.js` | Interface de l'espace DSI |
## Ce que la V7.11 ne fait pas
- **Pas d'annuaire ni de SSO** : le jeton reste un secret partagé hors de l'application (courriel,
papier). Un raccordement LDAP ou OpenID Connect resterait à faire, et ne toucherait que
`RoleController` et `CompteModel`.
- **Pas d'expiration automatique des jetons** : un jeton vit tant que la DSI ne le régénère pas. Une
date de péremption serait une colonne de plus sur `compte_acces` et une condition de plus dans
`v_acces_compte`.
- **Pas d'export du journal** : il se consulte à l'écran, filtre par filtre. Un export CSV
demanderait un endpoint supplémentaire.
- **Pas de purge automatique** : la purge est déclenchée à la main par la DSI, et journalisée.

133
docs/README_V7_12.md Normal file
View File

@@ -0,0 +1,133 @@
# PTA V7.12 — Second facteur pour les comptes Service Enfance et DSI
## Le problème traité
La V7.11 a remplacé le sélecteur de rôle par un jeton personnel. C'était un progrès décisif, mais un
jeton reste **un secret qui circule** : il est produit par la DSI, transmis par courriel ou sur
papier, parfois recopié dans un carnet. Un jeton intercepté suffisait à valider les plannings de
toute la collectivité (Service Enfance), voire à distribuer de nouveaux accès (DSI).
Le risque n'est pas le même selon le compte. Un agent ne peut que lire son propre planning ; un
responsable ne touche qu'aux brouillons de son lieu. Ces deux rôles-là restent à un seul facteur —
alourdir leur connexion coûterait plus qu'il ne protégerait.
## Ce qui change
### 1. Deux facteurs, mais seulement là où c'est justifié
| Rôle | Jeton | Mot de passe |
| --- | --- | --- |
| Agent | oui | — |
| Responsable de structure | oui | — |
| **Service Enfance** | oui | **oui** |
| **DSI** | oui | **oui** |
La règle vit **en base**, dans la vue `v_acces_compte` (`mot_de_passe_requis`), comme le reste des
droits depuis la V7.8 : étendre le second facteur à un autre rôle est une modification SQL, pas un
redéploiement.
Les deux secrets sont volontairement de nature différente : le jeton est **remis** par la DSI, le
mot de passe est **choisi** par le titulaire et n'est jamais affiché nulle part.
### 2. Le mot de passe se choisit à la première connexion
La DSI ne pose aucun mot de passe initial, et ne peut donc en connaître aucun. Le parcours :
1. la DSI crée le compte et remet le jeton ;
2. à la première connexion, le jeton est reconnu et l'écran **impose de choisir un mot de passe** ;
3. aux connexions suivantes : jeton, puis mot de passe.
Entre les deux étapes, **rien n'est ouvert** : la session ne porte qu'une attente horodatée, périmée
au bout de cinq minutes. Un jeton validé sans mot de passe ne donne accès à aucune page — vérifié :
`dsi.php` renvoie alors vers l'écran de connexion.
Le compte est **relu en base entre les deux étapes** : si la DSI révoque le compte pendant que
l'utilisateur tape son mot de passe, la session ne s'ouvre pas.
### 3. Politique : la longueur, et rien d'autre
- **12 caractères minimum**, 72 maximum. Aucune règle de composition : exiger une majuscule et un
chiffre produit `Motdepasse1`, pas un secret. Une phrase dont on se souvient vaut mieux.
- La borne haute n'est pas arbitraire : `password_hash()` utilise bcrypt, qui **ignore
silencieusement** tout ce qui dépasse 72 octets. Mieux vaut refuser que laisser croire.
- Le jeton lui-même est refusé comme mot de passe : le recopier ramènerait les deux facteurs à un
seul secret.
- Hachage par `password_hash()` (bcrypt), avec remise à niveau automatique si PHP change
d'algorithme par défaut. C'est un secret choisi par un humain, donc attaquable par dictionnaire :
contrairement au jeton, le hachage lent a ici tout son sens.
### 4. Changement et oubli
- **Changer son mot de passe** : bouton « Mot de passe » dans l'en-tête, visible des seuls comptes
concernés. L'ancien mot de passe est exigé — sans quoi un poste laissé ouvert suffirait à
confisquer un compte.
- **Oubli** : la DSI **efface** le mot de passe depuis sa page (bouton « Effacer le mot de passe »).
Elle ne le remplace pas : le titulaire en choisit un nouveau à sa connexion suivante, avec son
jeton inchangé. La DSI ne connaît donc jamais le mot de passe de personne, pas même à l'instant de
la réinitialisation.
- La colonne « État » de la liste des comptes affiche « Mot de passe défini » ou « Mot de passe à
définir ». Le second n'est pas une anomalie : c'est l'état normal d'un compte neuf.
### 5. Le frein anti-force-brute couvre les deux facteurs
Le compteur du journal (`JournalModel::echecsRecents()`) additionne désormais `JETON_REFUSE` et
`MOT_DE_PASSE_REFUSE` : essayer mille mots de passe derrière un jeton volé bute sur le même mur
qu'essayer mille jetons — 10 échecs en 15 minutes par adresse IP, puis plus rien n'est comparé.
### 6. Ce que le journal enregistre en plus
| Action | Catégorie | Quand |
| --- | --- | --- |
| `JETON_ACCEPTE_ATTENTE_MDP` | `CONNEXION` | Jeton reconnu, mot de passe attendu |
| `MOT_DE_PASSE_REFUSE` | `CONNEXION` | Mot de passe incorrect derrière un jeton valide |
| `MOT_DE_PASSE_DEFINI` | `ECRITURE` | Premier choix par le titulaire |
| `MOT_DE_PASSE_MODIFIE` | `ECRITURE` | Changement par le titulaire |
| `MOT_DE_PASSE_REINITIALISE` | `ADMINISTRATION` | Effacement par la DSI |
`CONNEXION_REUSSIE` porte en contexte les facteurs réellement présentés (`["jeton"]` ou
`["jeton","mot_de_passe"]`) : le journal distingue donc une connexion à un facteur d'une connexion à
deux.
## Mise en service
Rien à faire hors la migration `database/migration_v7_9f_mot_de_passe_admin.sql`, appliquée
automatiquement au démarrage. **Les comptes Service Enfance et DSI existants n'ont pas de mot de
passe** : leur prochaine connexion, jeton en main, leur en fera choisir un. Aucun jeton n'est
invalidé, aucun compte n'est bloqué.
Prévenez les titulaires concernés : ils verront un écran qu'ils n'attendent pas.
### Si le dernier accès DSI perd son mot de passe
Le cas ne se résout pas dans l'interface, puisqu'il faudrait être connecté pour le faire. En SQL :
```sql
UPDATE compte_acces SET mot_de_passe_hash = NULL, date_mot_de_passe = NULL WHERE id_compte = 1;
```
La connexion suivante, avec le jeton, redemandera d'en définir un. Si le jeton lui-même est perdu,
voir la procédure de `README_V7_11.md`.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `database/migration_v7_9f_mot_de_passe_admin.sql` | Colonnes `mot_de_passe_hash` / `date_mot_de_passe`, vue `v_acces_compte` enrichie de `mot_de_passe_requis` et `mot_de_passe_defini` |
| `app/Controllers/RoleController.php` | Connexion en deux étapes, définition, vérification, changement |
| `app/Models/CompteModel.php` | Politique, hachage, vérification, réinitialisation |
| `app/Models/JournalModel.php` | Frein étendu au second facteur |
| `app/Controllers/DsiController.php`, `api/dsi_compte_mot_de_passe.php` | Effacement par la DSI |
| `app/Views/pages/role.php` | Les quatre états de l'écran de connexion |
| `app/Views/layouts/header.php` | Lien « Mot de passe », pour les seuls comptes concernés |
## Ce que la V7.12 ne fait pas
- **Pas de verrouillage du compte** après N échecs : seul le frein par adresse IP s'applique. Un
verrouillage par compte permettrait à quiconque connaît un jeton de bloquer volontairement un
administrateur.
- **Pas d'expiration ni d'historique des mots de passe** : forcer un changement tous les trois mois
produit `Motdepasse-Mars`, `Motdepasse-Juin`. Le mot de passe se change quand il y a une raison.
- **Pas de réinitialisation par courriel** : il n'y a pas d'envoi de courriel dans PTA, et la boîte
aux lettres deviendrait le vrai facteur d'authentification.
- **Pas de TOTP** : le jeton joue déjà le rôle du « quelque chose que l'on détient ». Un
authentificateur mobile serait le prolongement naturel si le besoin s'en faisait sentir.

51
docs/README_V7_13.md Normal file
View File

@@ -0,0 +1,51 @@
# PTA V7.13 — Nom et prénom modifiables sur la fiche agent
## Le problème traité
La modale « Modifier les informations de l’agent » couvrait tout sauf l’identité : coordonnées,
diplôme, poste, contrat, lieu principal, commentaire PTA. Le nom et le prénom, eux, n’étaient saisis
qu’à la création.
Or ils changent : mariage, divorce, nom d’usage, ou plus prosaïquement une faute de frappe lors de la
saisie initiale. La seule correction possible passait par une requête SQL — donc par quelqu’un qui a
l’accès à la base, ce que la V7.11 s’était justement employée à restreindre.
## Ce qui change
Deux champs, **Prénom** et **Nom**, ouvrent la modale d’édition. Ils sont obligatoires et bornés à
100 caractères, comme à la création.
Restent volontairement hors d’atteinte :
- le **matricule** — c’est la clé de rapprochement avec les outils RH, et les plannings comme les PDF
déjà produits s’y réfèrent ;
- la **quotité de travail** — elle détermine le quota annuel déjà consommé ; la changer en cours
d’année fausserait rétroactivement tous les suivis.
Un renommage se lit **directement dans le journal**, sans avoir à comparer deux lignes :
```
AGENT_MODIFIE Modification de la fiche de l’agent Marc-Antoine LOUIS, renommé depuis « Marc Louis ».
{"id_structure":1,"type_contrat":"PERMANENT","identite_precedente":"Marc Louis"}
```
L’identité précédente n’apparaît que s’il y a effectivement eu changement : une modification de
coordonnées garde la trace courte.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `app/Views/partials/administration.php` | Les deux champs dans la modale, `data-nom` / `data-prenom` sur la ligne du tableau |
| `assets/js/administration.js` | Pré-remplissage depuis les `data-*`, envoi, rafraîchissement de la ligne |
| `app/Controllers/AgentController.php` | Validation, fiche relue avant modification pour repérer le renommage |
| `app/Models/AgentModel.php` | `nom` et `prenom` dans l’`UPDATE` de `updateProfile()` |
Aucune migration : les colonnes existent depuis le socle.
## Point d’attention
Les `data-*` de la ligne du tableau sont la SOURCE de la modale : elle s’ouvre à partir d’eux, sans
appel réseau. Ajouter un champ modifiable impose donc de le poser à trois endroits — le partiel
(attribut `data-*`), `setAgentRowData()` et `openAgentProfileModal()` — sans quoi il s’ouvre vide
puis écrase la valeur existante à l’enregistrement.

80
docs/README_V7_14.md Normal file
View File

@@ -0,0 +1,80 @@
# PTA V7.14 — Groupe « Management » : les agents et les lieux ont chacun leur onglet
## Le problème traité
L’onglet « Administration » réunissait trois objets sans rapport les uns avec les autres :
- le référentiel des **agents** (création, fiches, rattachements) ;
- le référentiel des **lieux d’affectation** (création, type périscolaire/extrascolaire) ;
- les **horaires de couverture attendus**, qui ne décrivent ni une personne ni un lieu mais une
exigence de présence.
L’écran était aussi organisé à l’envers : les deux formulaires de création côte à côte en haut, puis
les deux tableaux plus bas, séparés par les horaires de couverture. Créer un agent puis vérifier sa
fiche demandait de traverser tout l’écran.
## Ce qui change
### 1. Un groupe de navigation « Management », deux onglets
La barre de navigation gagne un cinquième groupe, au même titre que « Piloter », « Planifier »,
« Consulter » et « Paramétrer » :
| Groupe | Onglets |
| --- | --- |
| Piloter | Tableau de bord, À valider, Couverture |
| Planifier | Saisie, Équipes |
| Consulter | Par agent, Par lieu, Vue annuelle, PTA détaillé |
| **Management** | **Agents**, **Lieux d’affectation** |
| Paramétrer | Vacances, Administration, Accès & journaux |
Chaque onglet porte **la création puis la liste** de son objet : « Ajouter un agent » puis
« Informations des agents » ; « Ajouter un lieu » puis « Paramètres des lieux ». C’est l’ordre du
travail réel.
Le groupe se place volontairement **avant** « Paramétrer » : on y crée et corrige au fil de l’eau,
là où « Paramétrer » se règle une fois pour l’année.
### 2. « Administration » ne garde que la couverture
Il ne reste que l’encadré « Horaires de couverture des lieux » — le paramètre que contrôle l’onglet
« Couverture ». Le sous-titre de la page le dit, et la page ne charge plus que le module `coverage` :
les 578 lignes du module `administration` partent avec les deux nouveaux onglets.
### 3. Rien n’a changé côté serveur
Endpoints, gardes, contrôles et modules JS sont **identiques** : c’est un déplacement de markup, pas
une réécriture. Les identifiants HTML — points d’ancrage du module `administration` — sont conservés
à la lettre, et ce module fonctionne indifféremment sur l’un ou l’autre onglet : ses écouteurs sont
tous posés en `?.`, donc sans effet quand l’élément visé n’est pas sur la page.
Deux adaptations d’affichage : le raccourci du tableau de bord pointe sur « Agents », et le guide
d’utilisation renvoie aux nouveaux onglets. La tuile « Lieux à paramétrer » continue de mener à
« Administration » : elle parle bien de couverture.
## Une singularité de nommage à connaître
Les deux méthodes de `PageController` s’appellent `manage_agents()` et `manage_structures()`, en
snake_case, à rebours de tout le reste du projet. Ce n’est pas un oubli : 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 (`dashboard`, `coverage`, `pending`…). Garder les deux identiques prime sur l’uniformité
de style — c’est cette identité qui permet à PTA de se passer de routeur.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `database/migration_v7_9g_management.sql` | Droits d’accès aux deux pages pour le Service Enfance |
| `manage_agents.php`, `manage_structures.php` | Points d’entrée |
| `app/Views/pages/manage_*.php`, `app/Views/partials/manage_*.php` | Vues et markup déplacé |
| `app/Views/partials/administration.php` | Ne conserve que les horaires de couverture |
| `app/Controllers/PageController.php` | `manage_agents()`, `manage_structures()`, `administration()` allégé |
| `app/Views/layouts/header.php` | Groupe « Management » dans `$navigationGroups` |
| `app/Views/partials/dashboard.php`, `app/Views/partials/faq.php` | Renvois mis à jour |
## Point d’attention
Sans la migration, les pages sont refusées et les onglets n’apparaissent pas — c’est la règle de PTA
depuis la V7.8 : **les pages autorisées sont lues en base**, jamais écrites en PHP. La migration
supprime au passage la ligne `management` d’une version intermédiaire : une page inconnue laissée
dans `acces_role_page` finirait dans le groupe de repêchage « Autres » de la barre de navigation.

90
docs/README_V7_15.md Normal file
View File

@@ -0,0 +1,90 @@
# PTA V7.15 — Les affectations sont bornées par le contrat de l’agent
## Le problème traité
La fiche agent portait `date_debut_contrat` et `date_fin_contrat` depuis l’origine — saisies,
contrôlées l’une par rapport à l’autre, affichées dans les écrans et les PDF. Mais **aucun contrôle
de planification ne les lisait** : rien n’empêchait de planifier un agent trois mois avant son
arrivée, ou un contrat temporaire l’année suivant son départ.
Les conséquences n’étaient pas cosmétiques : ces heures entraient dans les cumuls annuels, dans le
quota, dans les contrôles de couverture — un lieu pouvait passer pour couvert par un agent qui
n’était pas encore embauché.
## Ce qui change
### 1. Toute écriture de créneau est bornée
La règle est posée sur **tous** les chemins qui créent ou déplacent une affectation :
| Chemin | Ce qui est contrôlé |
| --- | --- |
| Enregistrement d’un brouillon | Chaque date saisie |
| Modification d’un créneau (fiche agent) | La nouvelle date |
| Déplacement / duplication d’un créneau | La date cible |
| Copie d’une semaine entière | Les cinq jours ouvrés de la semaine cible |
| Application d’une semaine type | Les cinq jours ouvrés de la semaine visée |
| Duplication sur une période | Les deux bornes de la période |
Le refus est un **422** portant `code: HORS_CONTRAT`, la liste des dates refusées, et une phrase qui
nomme la période réellement couverte :
> Marc Louis n’est pas sous contrat à cette période : son contrat court à partir du 04/05/2026.
> Date(s) refusée(s) : 02/03/2026.
La règle vaut pour **tous les motifs**, congés et absences compris : hors contrat, il n’y a rien à
décompter.
Une borne absente **ouvre**, elle ne ferme jamais : pas de date de fin signifie contrat à durée
indéterminée ; pas de date de début (fiche ancienne) signifie qu’aucune borne basse n’est opposée,
plutôt que d’inventer une date.
### 2. La validation révèle les affectations déjà hors contrat
Les gardes ci-dessus empêchent d’en créer de nouvelles ; elles ne disent rien de celles qui
existaient avant. Un contrôle bloquant `HORS_CONTRAT` rejoint donc les quatre contrôles de
validation existants, à la validation d’une semaine comme à celle d’une année entière :
> L’affectation #824 du 2026-05-11 est en dehors des dates de contrat de l’agent.
La comparaison est faite en SQL sur la fiche agent jointe : c’est l’état du contrat **au moment du
contrôle** qui fait foi.
### 3. Le contrat ne peut plus glisser sous des affectations existantes
Contrôle miroir, sans lequel le premier ne servirait à rien : modifier les dates de contrat d’un
agent est refusé si cela laisserait des affectations au-dehors. Le message dit combien et sur quelle
période, pour qu’on sache quoi retirer :
> Ces dates de contrat laisseraient 6 affectation(s) en dehors du contrat, entre le 11/05/2026 et le
> 14/08/2026. Supprimez-les d’abord, ou élargissez les dates.
## Vérifications effectuées
Onze cas exécutés sur la pile, tous conformes : saisie avant le début de contrat (refusée), dans le
contrat (acceptée), sur la semaine à cheval contenant le premier jour (acceptée), copie de semaine
hors contrat, saisie après la date de fin, déplacement et modification de créneau vers une date hors
contrat, raccourcissement de contrat sous des affectations existantes, contrôle de validation sur une
donnée devenue illégitime, application d’une semaine type sur une semaine puis sur une période hors
contrat.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `app/Models/AgentModel.php` | `contractRange()`, `contractCoversDate()`, `datesOutsideContract()`, `contractViolationMessage()`, `entriesOutsideContract()` |
| `app/Controllers/PlanningController.php` | Garde sur les quatre chemins d’écriture, contrôle de validation |
| `app/Controllers/WeekTemplateController.php` | Garde sur l’application à une semaine et sur une période |
| `app/Controllers/AnnualOverviewController.php` | Même contrôle à la validation annuelle |
| `app/Controllers/AgentController.php` | Refus d’un contrat qui orphelinerait des affectations |
| `app/Models/PlanningModel.php` | `entriesOutsideContract()` pour le contrôle de validation |
Aucune migration : les colonnes existent depuis le socle, seule leur exploitation change.
## Point d’attention
La garde est **dupliquée** dans `PlanningController` et `WeekTemplateController`, faute de base
commune entre contrôleurs — comme l’est déjà `parseWeek()`. Ce qui n’est pas dupliqué, c’est le
message : il est construit par `AgentModel::contractViolationMessage()`, pour que l’utilisateur lise
la même phrase quel que soit l’écran. **Toute nouvelle voie d’écriture de créneaux doit poser la même
garde** — le contrôle de validation n’est qu’un filet, pas un substitut.

11
dsi.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require __DIR__ . '/page_bootstrap.php';
/**
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
* Réservé au rôle DSI par acces_role_page — voir PageController::dsi().
*/
runPage('dsi');

11
manage_agents.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require __DIR__ . '/page_bootstrap.php';
/**
* Groupe « Management » (V7.14) — voir PageController::manage_agents().
* Réservé au Service Enfance par acces_role_page.
*/
runPage('manage_agents');

11
manage_structures.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require __DIR__ . '/page_bootstrap.php';
/**
* Groupe « Management » (V7.14) — voir PageController::manage_structures().
* Réservé au Service Enfance par acces_role_page.
*/
runPage('manage_structures');