From 1a67080268ac9d525ab4588e62e3cce9add88432 Mon Sep 17 00:00:00 2001 From: PTA Date: Tue, 25 Aug 2026 15:12:11 +0200 Subject: [PATCH] update --- CLAUDE.md | 15 + api/acces_compte_creer.php | 20 + api/acces_compte_jeton.php | 19 + api/acces_compte_statut.php | 19 + api/acces_comptes.php | 20 + api/acces_comptes_manquants.php | 19 + app/Controllers/AccessAccountController.php | 366 +++++ app/Controllers/PageController.php | 26 +- app/Models/AgentModel.php | 47 +- app/Models/CompteModel.php | 44 +- app/Views/layouts/footer.php | 6 +- app/Views/layouts/header.php | 2 +- app/Views/pages/role.php | 2 +- app/Views/partials/administration.php | 121 +- app/Views/partials/faq.php | 2 + app/Views/partials/manage_agents.php | 17 +- assets/js/access-tokens.js | 345 +++++ assets/js/administration.js | 9 +- assets/js/agent-access.js | 158 ++ assets/js/page-bootstrap.js | 13 +- assets/style.css | 14 + ...migration_v7_9h_jetons_service_enfance.sql | 56 + docs/README.md | 1287 ++++++++++++++++- docs/README_DOCKER.md | 129 -- docs/README_V7_27.md | 121 ++ 25 files changed, 2646 insertions(+), 231 deletions(-) create mode 100644 api/acces_compte_creer.php create mode 100644 api/acces_compte_jeton.php create mode 100644 api/acces_compte_statut.php create mode 100644 api/acces_comptes.php create mode 100644 api/acces_comptes_manquants.php create mode 100644 app/Controllers/AccessAccountController.php create mode 100644 assets/js/access-tokens.js create mode 100644 assets/js/agent-access.js create mode 100644 database/migration_v7_9h_jetons_service_enfance.sql delete mode 100644 docs/README_DOCKER.md create mode 100644 docs/README_V7_27.md diff --git a/CLAUDE.md b/CLAUDE.md index 890c0c1..02926b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,15 @@ et haché par `password_hash()`. La règle « qui doit avoir un mot de passe » 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. +Depuis la V7.27 (`README_V7_27.md`), le Service Enfance distribue lui-même les jetons des rôles +`AGENT` et `RESPONSABLE_STRUCTURE`, depuis l'onglet « Administration » +(`AccessAccountController`, `api/acces_*.php`). **La délégation s'arrête à ces deux rôles** : un +compte `SERVICE_ENFANCE` créé par le Service Enfance serait une auto-promotion, un compte `DSI` lui +ouvrirait le journal de ses propres actions. La borne est posée des deux côtés — +`AccessAccountController::ROLES_DELEGUES` en PHP, `can_manage_field_access` en base — et ne doit être +élargie ni d'un côté ni de l'autre. La DSI reste seule sur les comptes privilégiés, les mots de passe +et le journal. + `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. @@ -242,6 +251,12 @@ les navigateurs et le `expires 7d` de nginx servent l'ancienne version. - 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). +- **Un compte d'accès coupé retire l'agent des listes d'AFFECTATION** (V7.29) : sélecteur de la + saisie (`AgentModel::listForLocation()`) et composition des équipes (`allSelectable()`). Un agent + **sans compte** reste proposé — c'est l'état normal avant distribution des jetons, le filtrer + viderait les listes d'une installation neuve. Les écrans de consultation et le référentiel + « Agents » continuent de le montrer : c'est de là qu'on lui rétablit son accès. `allActive()` reste + la liste non filtrée ; ne pas confondre les deux. - Un agent peut être planifié sur plusieurs lieux la même semaine ; `id_structure` sur l'agent n'est qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures. - Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ; diff --git a/api/acces_compte_creer.php b/api/acces_compte_creer.php new file mode 100644 index 0000000..79dd943 --- /dev/null +++ b/api/acces_compte_creer.php @@ -0,0 +1,20 @@ +creerCompte(); diff --git a/api/acces_compte_jeton.php b/api/acces_compte_jeton.php new file mode 100644 index 0000000..e34f0c4 --- /dev/null +++ b/api/acces_compte_jeton.php @@ -0,0 +1,19 @@ +regenererJeton(); diff --git a/api/acces_compte_statut.php b/api/acces_compte_statut.php new file mode 100644 index 0000000..ca2422e --- /dev/null +++ b/api/acces_compte_statut.php @@ -0,0 +1,19 @@ +changerStatutCompte(); diff --git a/api/acces_comptes.php b/api/acces_comptes.php new file mode 100644 index 0000000..3656731 --- /dev/null +++ b/api/acces_comptes.php @@ -0,0 +1,20 @@ +comptes(); diff --git a/api/acces_comptes_manquants.php b/api/acces_comptes_manquants.php new file mode 100644 index 0000000..bc895ff --- /dev/null +++ b/api/acces_comptes_manquants.php @@ -0,0 +1,19 @@ +genererJetonsManquants(); diff --git a/app/Controllers/AccessAccountController.php b/app/Controllers/AccessAccountController.php new file mode 100644 index 0000000..ea58c0b --- /dev/null +++ b/app/Controllers/AccessAccountController.php @@ -0,0 +1,366 @@ +access = new Access($pdo); + $this->comptes = new CompteModel($pdo); + } + + /** + * GET api/acces_comptes.php — comptes de terrain et identités sans compte. + * + * Paramètres GET : role (dans la liste déléguée), actif, q. + */ + public function comptes(): void + { + $this->access->requireService(); + + $comptes = $this->comptes->lister([ + 'roles' => self::ROLES_DELEGUES, + 'role' => (string) ($_GET['role'] ?? ''), + 'actif' => (string) ($_GET['actif'] ?? ''), + 'q' => (string) ($_GET['q'] ?? ''), + ]); + + JsonResponse::send(200, [ + 'comptes' => $comptes, + // v_acces_compte_manquant ne produit que des identités AGENT et + // RESPONSABLE_STRUCTURE : elle est déjà dans le périmètre délégué. + 'manquants' => $this->comptes->manquants(), + 'roles' => $this->rolesDelegues(), + ]); + } + + /** + * POST api/acces_compte_creer.php — crée un compte de terrain et renvoie son + * jeton. + * + * Le libellé n'est pas saisi : pour ces deux rôles il est TOUJOURS déduit de + * l'identité (nom de l'agent, nom du lieu), par CompteModel. Un titulaire ne + * peut donc pas être maquillé sous un autre nom dans la liste. + * + * Charge utile : role, agent_id, structure_id, commentaire, csrf_token. + */ + public function creerCompte(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $role = strtoupper(trim((string) ($payload['role'] ?? ''))); + $this->assertRoleDelegue($role); + + $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null; + $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null; + $commentaire = trim((string) ($payload['commentaire'] ?? '')); + + try { + $resultat = $this->comptes->creer( + $role, + $agentId !== null ? (int) $agentId : null, + $structureId !== null ? (int) $structureId : null, + '', + $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) par le Service Enfance.', + (string) $compte['identite_libelle'], + (string) $compte['role'] + ), + 'contexte' => [ + 'role' => $compte['role'], + 'prefixe' => $compte['jeton_prefixe'], + 'canal' => 'DELEGATION_SERVICE_ENFANCE', + ], + ]); + + 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/acces_compte_jeton.php — régénère le jeton d'un compte de terrain. + * + * C'est la procédure du jeton perdu comme du jeton compromis : l'ancien + * cesse immédiatement de fonctionner. + * + * Charge utile : compte_id, csrf_token. + */ + public function regenererJeton(): void + { + $this->access->requireService(); + Request::requireMethod('POST'); + $payload = Request::json(); + Csrf::assertPayload($payload); + + $compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0); + $compte = $this->compteDelegue($compteId); + + try { + $jeton = $this->comptes->regenererJeton($compteId); + } catch (DomainException $e) { + JsonResponse::send(404, ['error' => $e->getMessage()]); + return; + } + + $apres = $this->comptes->trouver($compteId) ?? $compte; + Journal::administration('JETON_REGENERE', [ + 'cible_table' => 'compte_acces', + 'cible_id' => $compteId, + 'description' => sprintf( + 'Régénération du jeton de « %s » par le Service Enfance : l’ancien jeton est révoqué.', + (string) $apres['identite_libelle'] + ), + 'contexte' => [ + 'role' => $apres['role'], + 'prefixe' => $apres['jeton_prefixe'] ?? null, + 'canal' => 'DELEGATION_SERVICE_ENFANCE', + ], + ]); + + JsonResponse::send(200, [ + 'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.', + 'compte' => $apres, + 'jeton' => $jeton, + ]); + } + + /** + * POST api/acces_compte_statut.php — active, désactive ou supprime un compte + * de terrain. + * + * La suppression efface le compte mais PAS ses traces : le journal conserve + * le libellé figé de l'acteur. + * + * Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER), + * csrf_token. + */ + public function changerStatutCompte(): void + { + $this->access->requireService(); + 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, et sa lecture vaut contrôle de + // périmètre : après une suppression, il n'y aurait plus ni rôle à + // vérifier ni libellé à écrire dans le journal. Le compte de la session + // en cours est hors périmètre par construction (c'est un compte + // SERVICE_ENFANCE), il n'y a donc pas d'auto-désactivation à empêcher. + $compte = $this->compteDelegue($compteId); + + 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) par le Service Enfance.', + $libelles[$action], + (string) $compte['identite_libelle'], + (string) $compte['role'] + ), + 'contexte' => [ + 'role' => $compte['role'], + 'prefixe' => $compte['jeton_prefixe'], + 'canal' => 'DELEGATION_SERVICE_ENFANCE', + ], + ]); + + JsonResponse::send(200, [ + 'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']), + ]); + } + + /** + * POST api/acces_comptes_manquants.php — équipe d'un coup toutes les + * identités actives sans compte. + * + * Sans danger d'élargissement : v_acces_compte_manquant ne connaît que des + * agents et des lieux, jamais un compte privilégié. La réponse porte tous + * les jetons produits, en clair — seule occasion de les lire. + * + * Charge utile : csrf_token. + */ + public function genererJetonsManquants(): void + { + $this->access->requireService(); + 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) de terrain par le Service Enfance.', + count($produits) + ), + 'contexte' => ['nombre' => count($produits), 'canal' => 'DELEGATION_SERVICE_ENFANCE'], + ]); + + JsonResponse::send(200, [ + 'message' => $produits === [] + ? 'Tous les agents et tous les lieux actifs 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, + ]); + } + + /** + * Relit un compte et REFUSE tout ce qui sort du périmètre délégué. + * + * Le refus est un 403 explicite et non un 404 : masquer l'existence du + * compte n'apporterait rien (le Service Enfance sait parfaitement que la DSI + * existe) et rendrait le message incompréhensible à l'utilisateur de bonne + * foi qui a suivi un lien périmé. + * + * @return array Le compte, garanti dans le périmètre. + */ + private function compteDelegue(int $compteId): array + { + $compte = $this->comptes->trouver($compteId); + if ($compte === null) { + JsonResponse::send(404, ['error' => 'Compte introuvable.']); + } + + if (!in_array((string) $compte['role'], self::ROLES_DELEGUES, true)) { + Journal::refus('COMPTE_HORS_PERIMETRE', 'Tentative de gestion d’un compte privilégié hors délégation.', [ + 'cible_table' => 'compte_acces', + 'cible_id' => $compteId, + 'contexte' => ['role_cible' => $compte['role'], 'statut_http' => 403], + ]); + JsonResponse::send(403, [ + 'error' => 'Les comptes Service Enfance et DSI relèvent de la seule DSI.', + 'code' => 'ACCESS_DENIED', + ]); + } + + return $compte; + } + + /** Refuse un rôle demandé hors périmètre délégué, avant toute écriture. */ + private function assertRoleDelegue(string $role): void + { + if (in_array($role, self::ROLES_DELEGUES, true)) { + return; + } + + Journal::refus('ROLE_HORS_PERIMETRE', 'Tentative de création d’un compte hors délégation.', [ + 'cible_table' => 'compte_acces', + 'cible_id' => null, + 'contexte' => ['role_demande' => $role, 'statut_http' => 403], + ]); + JsonResponse::send(403, [ + 'error' => 'Vous ne pouvez créer que des comptes Agent et Responsable de structure. Les comptes Service Enfance et DSI relèvent de la DSI.', + 'code' => 'ACCESS_DENIED', + ]); + } + + /** + * Rôles proposés à la création, avec leur libellé. + * + * @return array + */ + private function rolesDelegues(): array + { + return [ + ['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'], + ['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'], + ]; + } +} diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php index 100e3bc..713440e 100644 --- a/app/Controllers/PageController.php +++ b/app/Controllers/PageController.php @@ -223,7 +223,9 @@ final class PageController extends Controller $this->access->requirePage('teams'); $this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [ 'pageSubtitle' => 'La composition des équipes sur les mercredis et les périodes de vacances.', - 'agents' => (new AgentModel($this->pdo))->allActive(), + // Liste d'AFFECTATION : un agent dont l'accès a été coupé n'est plus + // proposé à la composition d'une équipe (V7.29). + 'agents' => (new AgentModel($this->pdo))->allSelectable(), 'structures' => (new StructureModel($this->pdo))->allActive(), 'scripts' => ['teams.js'], 'modules' => ['teams'], @@ -254,13 +256,16 @@ final class PageController extends Controller { $this->access->requirePage('administration'); $this->renderPage('administration', 'Administration', 'administration', [ - '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. + 'pageSubtitle' => 'Les horaires de couverture attendus de chaque lieu, et les jetons d\'accès des agents et des responsables.', + // Depuis la V7.14, cet écran ne porte plus les agents ni les lieux : + // ils ont leur propre page (management). Seule la liste des lieux + // reste nécessaire, pour son sélecteur. 'structures' => (new StructureModel($this->pdo))->allActive(), - 'scripts' => ['coverage.js'], - 'modules' => ['coverage'], + // V7.27 : la distribution des jetons de terrain rejoint cet écran. + // Le contenu de la section vient entièrement de l'API — le HTML + // servi ne porte aucune information sur les comptes. + 'scripts' => ['coverage.js', 'access-tokens.js'], + 'modules' => ['coverage', 'accessTokens'], ]); } @@ -289,8 +294,11 @@ final class PageController extends Controller 'agents' => $agentModel->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(), 'posts' => $agentModel->allPosts(), - 'scripts' => ['administration.js', 'address-autocomplete.js', 'field-format.js'], - 'modules' => ['administration', 'addressAutocomplete', 'fieldFormat'], + // V7.28 : `agentAccess` ajoute sur chaque ligne le bouton qui coupe + // ou rétablit l'accès de l'agent. Il n'ouvre aucun droit nouveau — + // il réutilise les endpoints délégués de la V7.27. + 'scripts' => ['administration.js', 'address-autocomplete.js', 'field-format.js', 'agent-access.js'], + 'modules' => ['administration', 'addressAutocomplete', 'fieldFormat', 'agentAccess'], ]); } diff --git a/app/Models/AgentModel.php b/app/Models/AgentModel.php index bc7bfc5..2f84726 100644 --- a/app/Models/AgentModel.php +++ b/app/Models/AgentModel.php @@ -221,7 +221,37 @@ final class AgentModel extends BaseModel SQL; /** - * Tous les agents actifs. + * Retire des listes D'AFFECTATION les agents dont le COMPTE D'ACCÈS est + * désactivé (V7.29). + * + * Trois précisions, toutes portées par le `NOT EXISTS` : + * + * - un agent SANS compte reste proposé. Il n'a simplement pas encore reçu + * son jeton, ce qui est l'état normal avant distribution : le filtrer + * viderait les listes d'une installation neuve ; + * - le filtre porte sur `compte_acces.actif`, PAS sur `agent.actif`, qui + * est déjà exigé par ailleurs. Ce sont deux décisions distinctes : + * fermer une fiche d'agent et couper un accès ; + * - il ne s'applique QU'AUX listes où l'on affecte. Les écrans de + * consultation (planning par agent, vue annuelle, PTA détaillé) et le + * référentiel « Agents » continuent de montrer l'agent — sans quoi on ne + * pourrait plus ni lire son année passée, ni lui rétablir son accès. + * + * La table compte_acces est garantie présente : SchemaModel::assertCurrent() + * refuse de démarrer si les vues de comptes manquent. + */ + private const SANS_COMPTE_COUPE = <<<'SQL' + AND NOT EXISTS ( + SELECT 1 FROM compte_acces ca + WHERE ca.id_agent = a.id_agent AND ca.actif = 0 + ) + SQL; + + /** + * Tous les agents actifs, y compris ceux dont l'accès est coupé. + * + * Liste de RÉFÉRENTIEL et de CONSULTATION. Pour une liste où l'on affecte, + * voir allSelectable(). * * @return array> */ @@ -232,6 +262,19 @@ final class AgentModel extends BaseModel )->fetchAll(); } + /** + * Agents proposables dans une liste d'affectation : actifs ET dont le compte + * d'accès n'a pas été coupé. + * + * @return array> + */ + public function allSelectable(): array + { + return $this->pdo->query( + self::AGENT_SELECT . " WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . " ORDER BY a.nom, a.prenom" + )->fetchAll(); + } + /** * Agents visibles depuis un lieu : ceux qui y sont RATTACHÉS, plus ceux qui * y ont un planning, quel que soit leur rattachement. @@ -328,7 +371,7 @@ final class AgentModel extends BaseModel FROM agent a LEFT JOIN poste_agent po ON po.id_poste = a.id_poste LEFT JOIN structure s ON s.id_structure = a.id_structure - WHERE a.actif = TRUE + WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . " ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom" ); $stmt->execute(['selected_location' => $selectedLocation]); diff --git a/app/Models/CompteModel.php b/app/Models/CompteModel.php index 99cda69..951de35 100644 --- a/app/Models/CompteModel.php +++ b/app/Models/CompteModel.php @@ -125,9 +125,16 @@ final class CompteModel extends BaseModel } /** - * Liste des comptes pour la page DSI. + * Liste des comptes pour la page DSI et pour la page « Jetons d'accès » du + * Service Enfance. * - * @param array $filtres role, actif (0|1), q (texte libre). + * `roles` BORNE la liste à un ensemble de rôles. C'est ce qui permet à la + * délégation V7.27 de ne jamais laisser voir un compte privilégié : le + * contrôleur passe sa liste blanche, et aucun filtre d'interface ne peut + * l'élargir — `role` ne fait que restreindre à l'intérieur de `roles`. + * + * @param array $filtres role, actif (0|1), q (texte libre), + * roles (array facultatif). * * @return array> */ @@ -136,10 +143,27 @@ final class CompteModel extends BaseModel $conditions = []; $params = []; + $rolesAutorises = array_values(array_filter(array_map( + static fn (mixed $role): string => strtoupper(trim((string) $role)), + (array) ($filtres['roles'] ?? []) + ))); + if ($rolesAutorises !== []) { + $marqueurs = []; + foreach ($rolesAutorises as $index => $roleAutorise) { + $marqueurs[] = ':role_autorise_' . $index; + $params['role_autorise_' . $index] = $roleAutorise; + } + $conditions[] = 'role IN (' . implode(', ', $marqueurs) . ')'; + } + $role = (string) ($filtres['role'] ?? ''); - if ($role !== '') { + // Le filtre d'interface ne peut que restreindre : un rôle hors de la + // liste blanche ne rend rien, il ne la contourne pas. + if ($role !== '' && ($rolesAutorises === [] || in_array(strtoupper($role), $rolesAutorises, true))) { $conditions[] = 'role = :role'; $params['role'] = $role; + } elseif ($role !== '') { + $conditions[] = '1 = 0'; } if (isset($filtres['actif']) && $filtres['actif'] !== '') { @@ -149,8 +173,18 @@ final class CompteModel extends BaseModel $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 . '%'; + // UN MARQUEUR PAR COLONNE, et non un seul répété quatre fois : PDO + // tourne ici sans émulation de requêtes préparées (ATTR_EMULATE_ + // PREPARES = 0), et MariaDB refuse alors un même paramètre nommé + // employé plusieurs fois — « Invalid parameter number ». Répéter + // « :q » faisait échouer toute recherche en 500. + $colonnes = ['identite_libelle', 'libelle', 'matricule', 'jeton_prefixe']; + $comparaisons = []; + foreach ($colonnes as $index => $colonne) { + $comparaisons[] = $colonne . ' LIKE :q_' . $index; + $params['q_' . $index] = '%' . $recherche . '%'; + } + $conditions[] = '(' . implode(' OR ', $comparaisons) . ')'; } $where = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions); diff --git a/app/Views/layouts/footer.php b/app/Views/layouts/footer.php index 5229d28..afb96a4 100644 --- a/app/Views/layouts/footer.php +++ b/app/Views/layouts/footer.php @@ -51,10 +51,10 @@ window.PTA_PAGE_CONFIG = $pageConfig, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>; - + - + - + diff --git a/app/Views/layouts/header.php b/app/Views/layouts/header.php index b0802e1..896c497 100644 --- a/app/Views/layouts/header.php +++ b/app/Views/layouts/header.php @@ -104,7 +104,7 @@ $showHelpTab = in_array('faq', $allowedPages, true); <?= htmlspecialchars($pageTitle) ?> — PTA - +
diff --git a/app/Views/pages/role.php b/app/Views/pages/role.php index dc9eab4..b66c6d8 100644 --- a/app/Views/pages/role.php +++ b/app/Views/pages/role.php @@ -43,7 +43,7 @@ $titre = $titres[$etape] ?? 'Connexion'; <?= htmlspecialchars($titre) ?> — PTA - +
diff --git a/app/Views/partials/administration.php b/app/Views/partials/administration.php index 64536b0..224d72a 100644 --- a/app/Views/partials/administration.php +++ b/app/Views/partials/administration.php @@ -1,7 +1,8 @@
@@ -43,4 +51,113 @@
+ + + + +
+
+
+

Jetons d’accès des agents et des responsables

+

Un compte par agent et par lieu d’affectation. Chacun se connecte avec son jeton : il n’y a pas de mot de passe pour ces deux rôles. Les comptes Service Enfance et DSI ne se gèrent pas ici — ils relèvent de la DSI.

+
+ +
+ +
+
+ + + + +
+

+
+
+ +
+
+ +
+ + + +
+ +
+
+ +
+
+ + + + + + + + + + + + + + +
TitulaireTypeJetonDernière connexionÉtatActions
Chargement des comptes…
+
+
diff --git a/app/Views/partials/faq.php b/app/Views/partials/faq.php index 1141ad9..c8b11d6 100644 --- a/app/Views/partials/faq.php +++ b/app/Views/partials/faq.php @@ -155,6 +155,8 @@ $sommaire = [
  • Adresse personnelle : facultative et purement informative. Dès trois caractères saisis, une liste d’adresses officielles est proposée sous le champ (source : Base Adresse Nationale) — cliquez sur la bonne, ou continuez à écrire librement si l’adresse n’y figure pas.
  • Observations utiles à la construction du PTA : disponibilités particulières, organisation connue… Ces notes se retrouvent sur l’écran « PTA détaillé ».
  • Cliquez sur Ajouter l’agent.
  • +
  • Pour couper l’accès d’un agent (départ, arrêt long) sans supprimer sa fiche : bouton Couper l’accès sur sa ligne, à côté de Modifier. Son jeton reste valable et resservira tel quel au rétablissement. L’agent disparaît alors des listes d’affectation — saisie des plannings et composition des équipes — mais reste consultable, et ses créneaux déjà saisis sont conservés.
  • +
  • Enfin, donnez-lui son jeton d’accès : onglet Administration, encadré « Jetons d’accès des agents et des responsables ». Le jeton n’est affiché qu’une seule fois — recopiez-le avant de refermer le panneau. Perdu, il ne se retrouve pas : il se régénère depuis le même écran, et l’ancien cesse alors de fonctionner. Les comptes Service Enfance et DSI, eux, restent gérés par la DSI.
  • Dates de contrat : 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.

    Pour corriger une fiche existante, utilisez le tableau « Informations des agents » du même onglet : le bouton Modifier ouvre la fiche complète, nom, prénom et lieu principal compris. Seuls le matricule et la quotité de travail 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.

    diff --git a/app/Views/partials/manage_agents.php b/app/Views/partials/manage_agents.php index 6787c60..5407d7d 100644 --- a/app/Views/partials/manage_agents.php +++ b/app/Views/partials/manage_agents.php @@ -20,6 +20,11 @@ * champ + `[data-address-suggestions]` + `[data-address-status]` — mais aucun * identifiant n'est connu du module. * + * V7.28 : chaque ligne d'agent porte, à côté de « Modifier », un bouton + * d'activation de son COMPTE D'ACCÈS (module `agentAccess`). Son libellé et son + * état sont posés par le JS après appel de l'API — le HTML servi ne dit rien des + * comptes, comme sur l'écran DSI. + * * Même principe pour l'e-mail et le téléphone : `data-format="email|phone"` sur * le champ et un `[data-format-status]` frère suffisent au module `fieldFormat` * à contrôler la saisie. Ce contrôle ne fait que PRÉVENIR : c'est @@ -140,7 +145,7 @@

    Informations des agents

    -

    Consultez la qualification, les coordonnées et le lieu principal de chaque agent. Le bouton Modifier ouvre la fiche complète.

    +

    Consultez la qualification, les coordonnées et le lieu principal de chaque agent. Le bouton Modifier ouvre la fiche complète ; le second bouton coupe ou rétablit l’accès de l’agent à l’application, sans toucher à son jeton.

    @@ -188,7 +193,15 @@ - + + + + + diff --git a/assets/js/access-tokens.js b/assets/js/access-tokens.js new file mode 100644 index 0000000..7c44cf3 --- /dev/null +++ b/assets/js/access-tokens.js @@ -0,0 +1,345 @@ +/** + * Jetons d'accès de terrain (`PTA.modules.accessTokens`), écran administration.php + * — V7.27. + * + * Le Service Enfance équipe ses agents et ses responsables de lieu : création + * d'un compte, régénération d'un jeton perdu, désactivation, suppression, et + * génération en lot pour les identités qui n'en ont pas encore. + * + * PÉRIMÈTRE : agents et responsables de lieu, RIEN D'AUTRE. Les comptes Service + * Enfance et DSI ne sont ni listés ni créables. Ce module ne les propose pas, et + * le serveur les refuse en 403 (AccessAccountController) : l'écran ne fait que + * refléter une frontière qu'il ne décide pas. + * + * RÈGLE CENTRALE, héritée de l'espace DSI : un jeton n'existe en clair que dans + * la réponse qui vient de le produire. Il est affiché dans un panneau dédié, qui + * reste ouvert tant qu'il n'a pas été refermé, et n'est jamais rechargé depuis + * le serveur — la base n'en garde qu'une empreinte. + */ +(() => { + '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); + + const LIBELLES_ROLE = { + RESPONSABLE_STRUCTURE: 'Responsable de structure', + AGENT: 'Agent', + }; + + /** + * Ce qu'il faut renseigner change selon le type de compte : l'écran + * l'explique plutôt que de laisser deviner pourquoi un champ change. + */ + const AIDES_ROLE = { + 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.', + }; + + /** Message dans un des bandeaux de la section. */ + 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é : toute réponse non-OK devient une exception. */ + 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 dans le 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 » → « 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('access-token-panel'); + const liste = el('access-token-list'); + if (!panneau || !liste || !jetons.length) return; + + liste.innerHTML = jetons.map((entree) => ` +
    +
    + ${escapeHtml(entree.libelle)} + ${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role)} +
    + ${escapeHtml(entree.jeton)} +
    + `).join(''); + + panneau.hidden = false; + message('access-token-message', `${jetons.length} jeton(s) à transmettre. Ils ne seront plus affichés après fermeture.`, '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('#access-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('access-token-message', `${lignes.length} jeton(s) copié(s) dans le presse-papiers.`, 'success'); + } catch (erreur) { + message('access-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('access-token-panel'); + if (!panneau) return; + panneau.hidden = true; + el('access-token-list').innerHTML = ''; + message('access-token-message', ''); + } + + // ----------------------------------------------------------------- comptes + + /** Une ligne du tableau des comptes. */ + function ligneCompte(compte) { + const identite = [ + `${escapeHtml(compte.identite_libelle)}`, + compte.matricule ? `
    Matricule ${escapeHtml(compte.matricule)}` : '', + compte.commentaire ? `
    ${escapeHtml(compte.commentaire)}` : '', + ].join(''); + + const etats = []; + if (!compte.actif) etats.push('Désactivé'); + else if (!compte.identite_active) etats.push('Identité inactive'); + else etats.push('Actif'); + + const connexions = compte.nombre_connexions > 0 + ? `
    ${compte.nombre_connexions} connexion(s)` + : ''; + + return ` + + ${identite} + ${escapeHtml(LIBELLES_ROLE[compte.role] || compte.role)} + ${escapeHtml(compte.jeton_prefixe)}… + ${formatDateHeure(compte.date_derniere_connexion)}${connexions} + ${etats.join(' ')} + + + + + + `; + } + + /** + * Remplit les listes 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('access-account-agent'); + const selectLieu = el('access-account-structure'); + if (selectAgent) { + selectAgent.innerHTML = '' + + agents.map((agent) => ``).join(''); + } + if (selectLieu) { + selectLieu.innerHTML = '' + + lieux.map((lieu) => ``).join(''); + } + + const compteur = el('access-missing-count'); + if (compteur) { + compteur.textContent = String(manquants.length); + compteur.hidden = manquants.length === 0; + } + const bouton = el('access-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('access-filter-role')?.value || ''; + const etat = el('access-filter-state')?.value || ''; + const recherche = el('access-filter-search')?.value.trim() || ''; + if (role) parametres.set('role', role); + if (etat !== '') parametres.set('actif', etat); + if (recherche) parametres.set('q', recherche); + + message('access-accounts-message', 'Chargement des comptes…'); + try { + const donnees = await api(`api/acces_comptes.php?${parametres.toString()}`); + const comptes = donnees.comptes || []; + el('access-accounts-body').innerHTML = comptes.length + ? comptes.map(ligneCompte).join('') + : 'Aucun compte ne correspond à ces filtres.'; + remplirIdentites(donnees.manquants || []); + message('access-accounts-message', `${comptes.length} compte(s) affiché(s).`, 'success'); + } catch (erreur) { + message('access-accounts-message', erreur.message, 'error'); + } + } + + /** Affiche les seuls champs d'identité utiles au type de compte choisi. */ + function ajusterFormulaireCompte() { + const role = el('access-account-role')?.value || ''; + const champAgent = el('access-account-agent-field'); + const champLieu = el('access-account-structure-field'); + if (!champAgent || !champLieu) return; + + champAgent.hidden = role !== 'AGENT'; + champLieu.hidden = role !== 'RESPONSABLE_STRUCTURE'; + el('access-account-agent').required = role === 'AGENT'; + el('access-account-structure').required = role === 'RESPONSABLE_STRUCTURE'; + + const aide = el('access-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('access-account-role').value; + + try { + const donnees = await poster('api/acces_compte_creer.php', { + role, + agent_id: role === 'AGENT' ? Number(el('access-account-agent').value) : null, + structure_id: role === 'RESPONSABLE_STRUCTURE' ? Number(el('access-account-structure').value) : null, + commentaire: el('access-account-comment').value, + }); + message('access-account-message', donnees.message, 'success'); + el('access-account-comment').value = ''; + afficherJetons([{ + libelle: donnees.compte?.identite_libelle || '', + role: donnees.compte?.role || role, + jeton: donnees.jeton, + }]); + await chargerComptes(); + } catch (erreur) { + message('access-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('.access-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/acces_compte_jeton.php', { compte_id: compteId }); + afficherJetons([{ + libelle: donnees.compte?.identite_libelle || titulaire, + role: donnees.compte?.role || '', + jeton: donnees.jeton, + }]); + message('access-accounts-message', donnees.message, 'success'); + } else if (evenement.target.closest('.access-toggle')) { + const desactiver = evenement.target.textContent.trim().startsWith('Dés'); + const donnees = await poster('api/acces_compte_statut.php', { + compte_id: compteId, + action: desactiver ? 'DESACTIVER' : 'ACTIVER', + }); + message('access-accounts-message', donnees.message, 'success'); + } else if (evenement.target.closest('.access-delete')) { + if (!window.confirm(`Supprimer définitivement le compte de ${titulaire} ? Son jeton cessera de fonctionner ; ses traces dans le journal sont conservées.`)) return; + const donnees = await poster('api/acces_compte_statut.php', { compte_id: compteId, action: 'SUPPRIMER' }); + message('access-accounts-message', donnees.message, 'success'); + } else { + return; + } + await chargerComptes(); + } catch (erreur) { + message('access-accounts-message', erreur.message, 'error'); + } + } + + /** Équipe d'un coup toutes les identités actives sans compte. */ + async function genererManquants() { + if (!window.confirm('Générer un jeton pour chaque agent et chaque lieu actif qui n’en a pas ? Ils seront affichés une seule fois.')) return; + try { + const donnees = await poster('api/acces_comptes_manquants.php', {}); + message('access-accounts-message', donnees.message, 'success'); + afficherJetons(donnees.jetons || []); + await chargerComptes(); + } catch (erreur) { + message('access-accounts-message', erreur.message, 'error'); + } + } + + function init() { + // La section n'existe que sur l'écran Administration : ailleurs, le + // module se retire sans rien tenter. + if (!el('access-accounts-body')) return; + + el('access-account-role')?.addEventListener('change', ajusterFormulaireCompte); + el('access-account-form')?.addEventListener('submit', creerCompte); + el('access-accounts-body')?.addEventListener('click', agirSurCompte); + el('access-accounts-refresh')?.addEventListener('click', chargerComptes); + el('access-filter-role')?.addEventListener('change', chargerComptes); + el('access-filter-state')?.addEventListener('change', chargerComptes); + el('access-generate-missing')?.addEventListener('click', genererManquants); + el('access-token-copy')?.addEventListener('click', copierJetons); + el('access-token-close')?.addEventListener('click', fermerJetons); + + ajusterFormulaireCompte(); + chargerComptes(); + } + + PTA.modules.accessTokens = { init, refresh: chargerComptes }; +})(); diff --git a/assets/js/administration.js b/assets/js/administration.js index 72807e3..806876e 100644 --- a/assets/js/administration.js +++ b/assets/js/administration.js @@ -252,7 +252,10 @@ ${escapeHtml(agent.telephone || 'Non renseigné')} ${escapeHtml(formatContractDate(agent.date_debut_contrat))} ${escapeHtml(structureLabel)} - + + + + `; } @@ -301,6 +304,10 @@ setAgentRowData(row, agent); row.innerHTML = agentRowHtml(agent); body.appendChild(row); + // L'agent vient de naître : il n'a pas encore de compte d'accès. Le + // module le dira sur la ligne, plutôt que d'y laisser un bouton + // inerte au libellé provisoire. + PTA.modules.agentAccess?.refresh?.(); } } diff --git a/assets/js/agent-access.js b/assets/js/agent-access.js new file mode 100644 index 0000000..6ce7bd2 --- /dev/null +++ b/assets/js/agent-access.js @@ -0,0 +1,158 @@ +/** + * Compte d'accès depuis la fiche des agents (`PTA.modules.agentAccess`), + * écran manage_agents.php — V7.28. + * + * Pose sur chaque ligne du tableau des agents un bouton qui COUPE ou RÉTABLIT + * l'accès de l'agent à l'application. C'est le geste courant — un départ, un + * arrêt long, un retour — là où l'écran Administration porte la distribution des + * jetons elle-même. + * + * CE QUE LE BOUTON NE FAIT PAS : il ne touche ni au jeton (désactiver puis + * réactiver rend le MÊME jeton valable), ni à la fiche de l'agent, qui reste + * planifiable. Il ne crée pas non plus de compte : un agent qui n'en a pas + * encore l'obtient depuis Administration, seul écran qui sache afficher un jeton + * fraîchement produit. + * + * Il réutilise les endpoints de la délégation V7.27 (api/acces_comptes.php et + * api/acces_compte_statut.php) : mêmes gardes serveur, même liste blanche de + * rôles, même journalisation. Rien de neuf côté droits. + * + * L'état des comptes N'EST PAS rendu par PHP mais chargé par ce module : le HTML + * servi ne porte ainsi aucune information sur les comptes, comme sur l'écran + * DSI. + */ +(() => { + 'use strict'; + const PTA = window.PTA; + if (!PTA) return; + + const { readJsonResponse, apiErrorMessage } = PTA.utils; + + const corps = () => document.querySelector('#agent-assignment-body'); + + /** Comptes d'accès des agents, indexés par identifiant d'agent. */ + let comptesParAgent = new Map(); + let chargement = false; + + function message(texte, type = 'info') { + const boite = document.querySelector('#assignment-message'); + if (!boite) return; + boite.className = texte ? `message message-${type}` : 'message'; + boite.textContent = texte; + } + + /** Appel d'API mutualisé : toute réponse non-OK devient une exception. */ + 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; + } + + /** + * Met un bouton de ligne à l'état du compte correspondant. + * + * Trois états, et un seul est actionnable : + * - compte actif → « Couper l'accès » ; + * - compte coupé → « Rétablir l'accès » ; + * - aucun compte → bouton inerte, qui dit où en créer un. + */ + function decorerLigne(ligne) { + const bouton = ligne.querySelector('.agent-access-toggle'); + if (!bouton) return; + + const agentId = Number(ligne.dataset.agentId); + const compte = comptesParAgent.get(agentId); + + if (!compte) { + bouton.disabled = true; + bouton.textContent = 'Sans compte d’accès'; + bouton.title = 'Cet agent n’a pas encore de jeton. Créez son compte depuis l’onglet Administration, section « Jetons d’accès ».'; + delete bouton.dataset.compteId; + delete bouton.dataset.actif; + return; + } + + bouton.disabled = false; + bouton.dataset.compteId = String(compte.id_compte); + bouton.dataset.actif = compte.actif ? '1' : '0'; + bouton.textContent = compte.actif ? 'Couper l’accès' : 'Rétablir l’accès'; + bouton.title = compte.actif + ? 'Désactive le compte : l’agent ne pourra plus se connecter. Son jeton reste valable et resservira au rétablissement.' + : 'Réactive le compte : l’agent pourra de nouveau se connecter avec le jeton qu’il détient déjà.'; + bouton.classList.toggle('button-danger', compte.actif); + bouton.classList.toggle('button-secondary', !compte.actif); + } + + /** Applique l'état chargé à toutes les lignes présentes. */ + function decorerToutesLesLignes() { + corps()?.querySelectorAll('tr[data-agent-id]').forEach(decorerLigne); + } + + /** + * Recharge l'état des comptes agents. + * + * L'échec est SILENCIEUX sur le tableau : les boutons restent inertes avec + * leur libellé d'attente, et la page des agents continue de fonctionner. Une + * commodité ne doit pas empêcher de consulter ou de modifier une fiche. + */ + async function refresh() { + if (!corps() || chargement) return; + chargement = true; + try { + const donnees = await api('api/acces_comptes.php?role=AGENT'); + comptesParAgent = new Map( + (donnees.comptes || []) + .filter((compte) => compte.id_agent !== null) + .map((compte) => [Number(compte.id_agent), compte]) + ); + decorerToutesLesLignes(); + } catch (erreur) { + message(`État des comptes d’accès indisponible : ${erreur.message}`, 'warning'); + } finally { + chargement = false; + } + } + + /** Coupe ou rétablit l'accès de l'agent d'une ligne. */ + async function basculer(evenement) { + const bouton = evenement.target.closest('.agent-access-toggle'); + if (!bouton || bouton.disabled) return; + + const ligne = bouton.closest('tr[data-agent-id]'); + const compteId = Number(bouton.dataset.compteId); + const actif = bouton.dataset.actif === '1'; + const titulaire = ligne?.querySelector('.agent-row-name')?.textContent?.trim() || 'cet agent'; + + if (actif && !window.confirm(`Couper l’accès de ${titulaire} ? Il ne pourra plus se connecter, et sa session en cours se refermera à sa prochaine action. Son jeton reste valable pour un rétablissement.`)) { + return; + } + + bouton.disabled = true; + try { + const donnees = await api('api/acces_compte_statut.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + csrf_token: window.PTA_CONFIG.csrfToken, + compte_id: compteId, + action: actif ? 'DESACTIVER' : 'ACTIVER', + }), + }); + message(donnees.message, 'success'); + await refresh(); + } catch (erreur) { + message(erreur.message, 'error'); + bouton.disabled = false; + } + } + + function init() { + // Le module ne sert que sur l'écran des agents : ailleurs, il se retire. + if (!corps()) return; + corps().addEventListener('click', basculer); + refresh(); + } + + PTA.modules.agentAccess = { init, refresh }; +})(); diff --git a/assets/js/page-bootstrap.js b/assets/js/page-bootstrap.js index d717410..2dd836c 100644 --- a/assets/js/page-bootstrap.js +++ b/assets/js/page-bootstrap.js @@ -54,7 +54,18 @@ } if (params.agentId && agentSelect) { agentSelect.value = String(params.agentId); - if (weekInput) weekInput.disabled = false; + // Depuis la V7.29, un agent dont le compte d'accès est coupé sort des + // listes d'affectation. Un lien profond qui le désigne (file de + // validation, tableau de bord, vue annuelle) ne trouve alors aucune + // option : sans ce message, l'écran resterait vide sans rien dire. + if (agentSelect.value !== String(params.agentId)) { + PTA.utils.showMessage( + 'Cet agent n’est plus proposé à la saisie : son accès a été coupé. Rétablissez-le depuis l’onglet Agents pour reprendre son planning.', + 'warning' + ); + } else if (weekInput) { + weekInput.disabled = false; + } } if (params.week && weekInput) { weekInput.disabled = !agentSelect?.value; diff --git a/assets/style.css b/assets/style.css index 5235d55..2e89aae 100644 --- a/assets/style.css +++ b/assets/style.css @@ -3394,3 +3394,17 @@ input.field-invalid { input.field-valid { border-color: #1b7f3b; } + +/* ============================================================ + V7.28 - Actions de ligne sur la fiche des agents + ============================================================ */ +.agent-row-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; +} + +.agent-row-actions .button { + white-space: nowrap; +} diff --git a/database/migration_v7_9h_jetons_service_enfance.sql b/database/migration_v7_9h_jetons_service_enfance.sql new file mode 100644 index 0000000..d00db40 --- /dev/null +++ b/database/migration_v7_9h_jetons_service_enfance.sql @@ -0,0 +1,56 @@ +-- ============================================================================ +-- PTA V7.27 — Le Service Enfance gère les jetons des agents et des responsables +-- ============================================================================ +-- La distribution des accès était entièrement portée par la DSI (V7.11). Dans +-- les faits, c'est le Service Enfance qui sait qu'un agent est arrivé, qu'un +-- responsable a perdu son jeton ou qu'un remplaçant part : passer par la DSI +-- pour chaque mouvement de terrain ajoutait un délai sans ajouter de contrôle. +-- +-- CETTE DÉLÉGATION EST STRICTEMENT BORNÉE À DEUX RÔLES : AGENT et +-- RESPONSABLE_STRUCTURE. Les comptes SERVICE_ENFANCE et DSI restent à la seule +-- main de la DSI, et ce n'est pas une commodité d'affichage : +-- * un compte SERVICE_ENFANCE créé par le Service Enfance serait une +-- auto-promotion — le rôle se distribuerait lui-même sans contrôle externe ; +-- * un compte DSI créé par le Service Enfance lui ouvrirait le journal +-- d'activité, c'est-à-dire la trace de ses propres actions. +-- La borne est doublée en PHP (AccessAccountController::ROLES_DELEGUES), parce +-- qu'une frontière de privilège ne doit pas dépendre d'une seule ligne de +-- données : une ligne mal saisie ici ne doit pas pouvoir ouvrir la création de +-- comptes privilégiés. +-- +-- POURQUOI « v7_9h » : 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_9g), pas le numéro de version fonctionnelle. Voir CLAUDE.md. +-- +-- Prérequis : migration_v7_8_securite_acces.sql (tables acces_role_*) et +-- migration_v7_9e_dsi_comptes_journal.sql (compte_acces, rôle DSI). +-- Idempotente : rejouable sans effet de bord. +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- AUCUNE PAGE NOUVELLE +-- ---------------------------------------------------------------------------- +-- La gestion des jetons prend place dans l'onglet « Administration », déjà +-- ouvert au Service Enfance depuis la V7.8 : c'est là que vivent les réglages +-- qu'on pose une fois, à côté des horaires de couverture. Il n'y a donc rien à +-- insérer dans acces_role_page — et rien qui puisse ouvrir cet écran à un autre +-- rôle par mégarde. +-- +-- ---------------------------------------------------------------------------- +-- LA PERMISSION +-- ---------------------------------------------------------------------------- +-- `can_manage_field_access` : distribuer les jetons DE TERRAIN (agents et +-- responsables de lieu). À ne pas confondre avec `can_manage_access`, qui reste +-- la permission de la DSI et couvre TOUS les comptes, y compris privilégiés. +-- +-- Les refus sont écrits explicitement plutôt que laissés absents : une ligne à +-- FALSE documente une décision, une ligne absente laisse croire à un oubli (une +-- permission inconnue vaut de toute façon « refusé »). +INSERT INTO acces_role_permission (role, permission, autorise) VALUES + ('SERVICE_ENFANCE', 'can_manage_field_access', TRUE), + -- La DSI n'en a pas besoin : can_manage_access lui ouvre déjà tous les + -- comptes. Lui donner celle-ci n'ajouterait rien et brouillerait la lecture. + ('DSI', 'can_manage_field_access', FALSE), + ('AGENT', 'can_manage_field_access', FALSE), + ('RESPONSABLE_STRUCTURE', 'can_manage_field_access', FALSE) +ON DUPLICATE KEY UPDATE autorise = VALUES(autorise); diff --git a/docs/README.md b/docs/README.md index 04ec58b..6f96208 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,114 +1,1251 @@ -# PTA V6 — application PHP MVC multipage +# PTA — Planning de Travail Annualisé -PTA n’est plus une SPA. Chaque fonction principale correspond à une page PHP distincte : +Documentation technique de l'application. Elle s'adresse aux développeurs qui reprennent le projet : +elle explique ce que fait l'application, comment elle est construite, et documente l'intégralité des +points d'entrée HTTP. -- `planning.php` : saisie, brouillon, validation et semaines types ; -- `pending.php` : plannings en attente de validation ; -- `coverage.php` : contrôle des créneaux sans agent ; -- `structures.php` : planning hebdomadaire par lieu ; -- `agents.php` : planning mensuel et bilan par agent ; -- `vacations.php` : aperçu et enregistrement des vacances scolaires ; -- `administration.php` : lieux, agents et règles de couverture. +> Les fichiers `README_V7_*.md` de ce dossier documentent chacun **une version successive** et +> servent d'historique des décisions. Ce document-ci décrit l'**état courant** du code. En cas de +> divergence, seul le code fait foi. -`index.php` redirige vers le choix de rôle lors de la première visite, puis vers la page d’accueil autorisée pour le profil sélectionné. +--- -## Architecture +## Sommaire -```text -page PHP - → PageController - → Models / Services - → View dédiée +1. [Ce que fait l'application](#1-ce-que-fait-lapplication) +2. [Contraintes techniques structurantes](#2-contraintes-techniques-structurantes) +3. [Démarrage](#3-démarrage) +4. [Architecture](#4-architecture) +5. [Rôles, authentification et droits](#5-rôles-authentification-et-droits) +6. [Journalisation](#6-journalisation) +7. [Sécurité — contraintes à respecter](#7-sécurité--contraintes-à-respecter) +8. [JavaScript](#8-javascript) +9. [Modèle métier](#9-modèle-métier) +10. [Piège : logique de validation dupliquée](#10-️-piège--logique-de-validation-dupliquée) +11. [Les pages](#11-les-pages) +12. [**Référence des endpoints API**](#12-référence-des-endpoints-api) + · [Planning](#121-planning--saisie-et-validation) + · [Semaines types](#122-semaines-types) + · [Agents](#123-agents) + · [PDF](#124-pdf) + · [Lieux & couverture](#125-lieux-daffectation-et-couverture) + · [Annuel & PTA](#126-vue-annuelle-et-pta) + · [Équipes](#127-équipes) + · [Vacances](#128-vacances-scolaires) + · [Tableau de bord](#129-tableau-de-bord-et-recherche-dadresse) + · [Comptes délégués](#1210-comptes-daccès-délégués--service-enfance-v727) + · [Espace DSI](#1211-espace-dsi--comptes-privilégiés-et-journal-v711) +13. [Base de données](#13-base-de-données) +14. [Où chercher quoi](#14-où-chercher-quoi) +15. [Checklist avant commit](#15-checklist-avant-commit) +16. [Historique des versions](#16-historique-des-versions) -View - → API JSON uniquement pour les actions asynchrones utiles +--- + +## 1. Ce que fait l'application + +PTA gère la **planification annualisée** des agents d'accueils de loisirs périscolaires et +extrascolaires d'une collectivité. + +Le principe métier : un agent ne travaille pas un nombre d'heures fixe chaque semaine, mais un +**volume annuel** (1 607 h de référence, proratisé par sa quotité de travail). Les semaines sont +donc inégales — beaucoup d'heures pendant les vacances scolaires, moins pendant le temps scolaire — +et l'application sert à composer ces semaines, à vérifier en continu que le cumul reste cohérent +avec le volume dû, et à valider le résultat. + +L'application permet de : + +- saisir semaine par semaine les créneaux d'un agent sur un lieu (`planning.php`) ; +- composer des **semaines types** réutilisables et les appliquer sur toute une période ; +- suivre le **quota annuel** consommé, mois par mois et sur l'année (`annual.php`, `pta.php`) ; +- contrôler la **couverture** : les plages horaires d'un lieu où il manque des agents ; +- faire **valider** les plannings par le Service Enfance, avec des contrôles bloquants ; +- composer les **équipes** des mercredis et des vacances, avec taux d'encadrement ; +- importer le calendrier des **vacances scolaires** depuis l'API officielle ; +- éditer des **PDF** (planning d'agent, planning de lieu, bilan d'heures, contrat horaire) ; +- administrer les **comptes d'accès** (jetons) et consulter le **journal d'activité**. + +--- + +## 2. Contraintes techniques structurantes + +**Aucune dépendance externe.** Pas de Composer, pas de npm, pas de framework, pas de bundler, pas de +suite de tests. Uniquement **PHP 8.3 + PDO/MariaDB + JavaScript vanilla**. Le générateur PDF +(`app/Services/SimplePdf.php`) et l'autoloader (`app/autoload.php`) sont volontairement réécrits à +la main. N'introduisez pas de dépendance sans raison forte. + +**Tout est en français** : identifiants SQL (`creneau_horaire`, `id_agent`, `statut`), messages +utilisateur, commentaires, noms de méthodes métier. Conservez cette langue pour tout nouveau code. + +**Pas de routeur.** Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes, à la racine ou +dans `api/`. + +--- + +## 3. Démarrage + +```bash +cp .env.example .env # puis renseigner DB_PASSWORD / DB_ROOT_PASSWORD +docker compose up -d --build ``` -Les contrôleurs métier, modèles, services PDF et endpoints API de la V5.20 sont conservés. La différence principale porte sur l’interface : plus de routeur JavaScript, plus de vues cachées et plus de chargement de tous les modules sur une seule page. +L'application est alors sur `http://localhost:8080`, phpMyAdmin sur `http://127.0.0.1:8081`. -## JavaScript chargé par page +### Services docker-compose -- Saisie : `planning-editor.js` et `week-templates.js` ; -- À valider : `pending-validation.js` ; -- Couverture : `coverage.js` ; -- Par lieu : `structure-overview.js` ; -- Par agent : `agent-overview.js` ; -- Vacances : `vacation-calendar.js` ; -- Administration : `administration.js` et la partie configuration de `coverage.js`. +| Service | Rôle | +|--------------|------| +| `db` | MariaDB 11.4. Le schéma `database/pta_prod_schema_mariadb.sql` n'est joué qu'à la **création initiale du volume**. | +| `db-migrate` | Conteneur jetable qui applique les `database/migration_*.sql` à chaque démarrage. | +| `php` | php-fpm 8.3. **Attend la sortie en succès de `db-migrate`** : une migration en échec empêche l'application de tourner sur une base incomplète. | +| `nginx` | Sert les pages PHP et les assets statiques. Publié sur `APP_PORT`. | +| `phpmyadmin` | N'écoute que sur `127.0.0.1` et exige un identifiant MariaDB. | -`assets/js/page-bootstrap.js` initialise uniquement les modules déclarés par la page et applique les filtres transmis dans l’URL. +### ⚠️ Le code est COPIÉ dans l'image, pas monté -## Liens directs +Toute modification PHP/JS/CSS exige un rebuild des **deux** services — nginx sert lui-même les +assets statiques depuis sa propre copie de `/var/www/html` : + +```bash +docker compose up -d --build php nginx +``` + +### Commandes utiles + +```bash +docker compose logs -f php nginx db +docker compose logs db-migrate # compte rendu de l'application des migrations +docker compose exec db mariadb -upta -p pta +docker compose down # arrêt en conservant les données +docker compose down -v # DÉTRUIT le volume MariaDB + +# Vérification syntaxique (il n'existe ni linter ni tests automatisés) +find . -name '*.php' -not -path './.git/*' -exec php -l {} \; +``` + +### Après une installation neuve + +1. Se connecter avec le **jeton DSI d'installation** `PTA-INSTALLATION-DSI`, écrit en clair dans + `database/migration_v7_9e_dsi_comptes_journal.sql` — il est donc **public**. +2. Le régénérer immédiatement depuis la page « Accès & journaux ». L'écran alerte tant que ce n'est + pas fait. +3. Générer les jetons manquants pour équiper les agents et les responsables de lieu. +4. Changer le mot de passe MariaDB du `.env`, encore à sa valeur d'exemple. + +### Migrations + +Les fichiers `database/migration_*.sql` sont appliqués automatiquement par `db-migrate` +(`docker/db/apply-migrations.sh`), sur base neuve comme sur base existante. Le script tient un +registre `schema_migration` (fichier + empreinte MD5) : un fichier déjà appliqué et inchangé est +ignoré, un fichier modifié est **rejoué**. + +Deux règles : + +- **Toute migration doit être 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_…`. C'est pourquoi les migrations récentes suivent le + préfixe de la précédente (`…_v7_9e_`, `…_v7_9f_`, `…_v7_9g_`, `…_v7_9h_`) et non 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`, `PMA_PORT` de `.env`) : les modifier coupe l'accès à la base. + +--- + +## 4. Architecture + +### 4.1 Arborescence + +``` +/ points d'entrée « pages » (planning.php, agents.php, …) +├── api/ points d'entrée JSON et PDF +├── app/ +│ ├── autoload.php autoloader PSR-4 maison +│ ├── bootstrap.php session + PDO + contrôle de schéma + journal +│ ├── Controllers/ logique applicative, gardes, validation +│ ├── Core/ Access, Csrf, Journal, JsonResponse, Request, Security, Session… +│ ├── Models/ SQL PDO uniquement, aucune règle d'accès +│ ├── Services/ appels d'API externes et génération PDF +│ └── Views/ +│ ├── layouts/ header.php (nav) et footer.php (injection JS) +│ ├── pages/ une vue par page +│ └── partials/ markup réutilisable +├── assets/ style.css et js/ (une IIFE par module) +├── database/ schéma initial + migrations +├── docker/ Dockerfiles, conf nginx, script de migration +└── docs/ cette documentation +``` + +### 4.2 Deux types de points d'entrée + +**Pages HTML** — fichiers à la racine : + +```php +require __DIR__ . '/page_bootstrap.php'; +runPage('planning'); // -> PageController::planning() +``` + +`page_bootstrap.php` attrape tout `Throwable` et rend une page d'erreur HTML autonome ; le détail +technique reste journalisé côté serveur par `Security::reportThrowable()`. + +**Endpoints JSON/PDF** — `api/*.php`, plus `save_planning.php`, `validate_planning.php` et +`reopen_planning.php` restés à la racine pour raisons historiques : + +```php +JsonResponse::bootstrap(); // handlers d'erreur -> JSON, jamais du HTML +$pdo = require dirname(__DIR__) . '/app/bootstrap.php'; +(new PlanningController($pdo))->show(); +``` + +`JsonResponse::bootstrap()` convertit warnings et exceptions en réponses JSON 500 — indispensable, +car le JS refuse toute réponse non-JSON. **Les endpoints PDF ne l'appellent pas** : ils émettent des +octets binaires via `PdfResponse::inline()` et sortent leurs erreurs en texte brut. + +### 4.3 Chaîne d'un appel + +``` +api/x.php → Controller (Access + Csrf + Request) → Model (SQL PDO) → JsonResponse::send() +page.php → PageController → Models → Views/pages/x.php + ├── layouts/header.php (nav filtrée par rôle) + ├── partials/x.php (markup) + └── layouts/footer.php (window.PTA_CONFIG + scripts) +``` + +`app/bootstrap.php` démarre la session, ouvre le PDO (`db.php` ← `config.php` ← variables +d'environnement `PTA_DB_*`), appelle `SchemaModel::assertCurrent()` **à chaque requête** et pose la +connexion du journal. + +> **Ajouter une table ou une colonne obligatoire impose de mettre à jour les listes de +> `SchemaModel`**, sinon l'application entière refuse de démarrer — ou, pire, démarre sans le +> contrôle. + +### 4.4 Ajouter un endpoint + +1. Créer le fichier mince dans `api/` (voir modèle ci-dessus). +2. Ajouter la méthode au contrôleur, avec **dans cet ordre** : + garde `Access` → `Request::requireMethod('POST')` → `Request::json()` → + `Csrf::assertPayload($payload)` → validation → modèle → `Journal::ecriture()` → + `JsonResponse::send()`. + +### 4.5 Ajouter une page + +1. Fichier PHP à la racine (`require page_bootstrap.php` + `runPage('cle')`). +2. Méthode dans `PageController` — **son nom EST la clé de page** utilisée par `acces_role_page` + (d'où `manage_agents()` en snake_case, à rebours du reste du projet). +3. Vue `app/Views/pages/cle.php` + partiel `app/Views/partials/cle.php`. +4. Module JS, déclaré dans les tableaux `scripts` / `modules` de la méthode. +5. Inscription de la clé dans `$navigation` **et** dans un groupe de `$navigationGroups` + (`app/Views/layouts/header.php`). +6. **Migration SQL** insérant la ligne dans `acces_role_page` : les pages autorisées sont lues en + base, pas écrites en PHP. + +Une page absente des groupes reste affichée dans un groupe « Autres » : ce filet évite qu'un oubli +la rende inatteignable. + +**Seule exception assumée : `faq`** (guide d'utilisation) est volontairement absente de +`$navigation` et rendue par le bloc `.view-tab-help` ancré à droite de la barre. L'omettre de +`$navigation` est ce qui l'empêche d'être aussi repêchée par « Autres » et d'apparaître deux fois. +Elle reste soumise à `acces_role_page` (`migration_v7_9d_faq.sql`). + +--- + +## 5. Rôles, authentification et droits + +### 5.1 Les quatre rôles + +| Rôle | Périmètre | +|------|-----------| +| `AGENT` | Lecture seule sur son propre planning. | +| `RESPONSABLE_STRUCTURE` | Édition des brouillons de **son seul lieu**. | +| `SERVICE_ENFANCE` | Tout le métier ; **seul habilité à valider**. Distribue les jetons de terrain. | +| `DSI` | Administration technique : comptes d'accès et journal. **Aucun droit métier.** | + +### 5.2 Authentification par jeton (V7.11) + +`role.php` n'est **pas** un sélecteur de rôle. 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. La base ne +conserve qu'une empreinte SHA-256 du jeton. + +`Access::select()` a disparu — **ne pas le réintroduire.** + +### 5.3 Second facteur pour les comptes privilégiés (V7.12) + +Les comptes `SERVICE_ENFANCE` et `DSI` exigent, après le jeton, un **mot de passe** choisi par le +titulaire à sa première connexion et haché par `password_hash()`. 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 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. La DSI peut **effacer** un mot de passe, +jamais en **poser** un — sans quoi elle connaîtrait celui de tout le monde. + +Un frein anti-force-brute compte les échecs récents via `JournalModel::echecsRecents()` : +10 échecs sur 15 minutes bloquent. **Renommer les actions `JETON_REFUSE` ou `MOT_DE_PASSE_REFUSE` +désarmerait ce frein silencieusement.** + +### 5.4 Délégation des jetons de terrain (V7.27) + +Le Service Enfance distribue lui-même les jetons des rôles `AGENT` et `RESPONSABLE_STRUCTURE`, +depuis l'onglet « Administration » (`AccessAccountController`, `api/acces_*.php`). + +**La délégation s'arrête à ces deux rôles** : un compte `SERVICE_ENFANCE` créé par le Service +Enfance serait une auto-promotion, un compte `DSI` lui ouvrirait le journal de ses propres actions. +La borne est posée **des deux côtés** — `AccessAccountController::ROLES_DELEGUES` en PHP, +`can_manage_field_access` en base — et ne doit être élargie ni d'un côté ni de l'autre. La DSI reste +seule sur les comptes privilégiés, les mots de passe et le journal. + +### 5.5 Relecture du compte à chaque requête + +`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.** + +### 5.6 Les droits vivent en SQL, pas en PHP + +**`Access.php` ne contient aucune règle de droit.** Tout est lu dans les vues créées par +`database/migration_v7_8_securite_acces.sql` : + +`v_acces_role_page`, `v_acces_role_permission`, `v_acces_identite_agent`, +`v_acces_identite_structure`, `v_acces_agent_par_structure`, `v_acces_perimetre_creneau`, +`v_acces_perimetre_planning` — plus `v_acces_compte`, `v_acces_compte_manquant` et +`v_journal_activite` (V7.11). + +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 au démarrage la présence des dix vues 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. + +Permissions actuelles : + +| Permission | Ce qu'elle ouvre | +|------------|------------------| +| `read_only` | Marque un rôle en lecture seule (AGENT). | +| `can_edit_draft` | Éditer les brouillons — garde `requireDraftEditor()`. | +| `can_edit_cross_structure` | Sortir du périmètre d'un seul lieu. | +| `can_validate` | Valider un planning. | +| `can_reopen` | Repasser un planning validé en brouillon. | +| `can_view_pending` | Voir la file d'attente de validation. | +| `can_use_templates` | Semaines types (conditionne aussi le chargement des scripts). | +| `can_copy_full_week` | Recopier une semaine entière d'un agent. | +| `can_forecast_next_year` | Projeter sur l'année suivante. | +| `can_manage_agents` | Référentiel des agents. | +| `can_manage_structures` | Référentiel des lieux. | +| `can_manage_coverage` | Règles de couverture. | +| `can_manage_teams` | Équipes des mercredis et vacances. | +| `can_manage_vacations` | Calendrier des vacances scolaires. | +| `can_manage_pta` | Contraintes et souhaits du PTA. | +| `can_manage_access` | Comptes d'accès, tous rôles (DSI). | +| `can_manage_field_access` | Comptes d'accès **de terrain uniquement** (Service Enfance, V7.27). | +| `can_read_journal` | Journal d'activité (DSI). | + +### 5.7 Les gardes de contrôleur + +Les contrôles sont **doublés** : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur +commence par un garde. Ces gardes émettent directement un 401/403 JSON et **coupent l'exécution** — +l'appelant n'a donc pas à tester leur retour. + +| Garde | Ce qu'il vérifie | +|-------|------------------| +| `requirePage($cle)` | Page autorisée pour le rôle (`acces_role_page`). Pages HTML uniquement. | +| `requireRoles([…])` | Appartenance à une liste de rôles. | +| `requireService()` | Rôle `SERVICE_ENFANCE`. | +| `requireDsi()` | Rôle `DSI`. | +| `requireDraftEditor()` | Permission `can_edit_draft`. | +| `requireStructureView($id)` | Droit de **consulter** ce lieu. | +| `requireStructureEdit($id)` | Droit d'**éditer** ce lieu (un responsable est borné au sien). | +| `requireAgentView($id)` | Droit de consulter cet agent (un AGENT est borné à lui-même). | +| `requireEntryEdit($id)` | Droit d'éditer ce créneau (`v_acces_perimetre_creneau`). | +| `requirePlanningEdit($id)` | Droit d'éditer ce planning (`v_acces_perimetre_planning`). | + +**Ne jamais ajouter un endpoint mutant sans garde ni sans `Csrf::assertPayload($payload)`.** + +### 5.8 CSRF + +Le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête — ce choix évite la +requête préliminaire CORS et garde les appels `fetch()` identiques d'un module à l'autre. + +```js +csrf_token: window.PTA_CONFIG.csrfToken +``` + +Il vit aussi longtemps que la session : il n'est pas renouvelé à chaque requête, sans quoi deux +onglets ouverts s'invalideraient l'un l'autre. La comparaison passe par `hash_equals()`. + +`role.php` fait exception et vérifie son CSRF à la main : les données y arrivent en formulaire +classique et un échec doit produire une page HTML, pas du JSON. + +--- + +## 6. Journalisation + +`App\Core\Journal` écrit dans `journal_activite`. Sa connexion PDO est posée une fois par +`app/bootstrap.php` — les appelants n'ont rien à transporter : + +```php +Journal::ecriture('PLANNING_VALIDE', 'planning', $planningId, 'Planning validé pour…', [contexte]); +``` + +Cinq catégories : `CONNEXION`, `ECRITURE`, `ACCES_REFUSE`, `ADMINISTRATION`, `SYSTEME`. + +**Trois règles :** + +1. **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()`. +2. **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. +3. **Le journal ne doit jamais faire échouer l'action** : `Journal` avale ses propres erreurs. Ne pas + entourer un appel de logique qui pourrait lever. + +Actions existantes (extrait) : `CONNEXION_REUSSIE`, `JETON_REFUSE`, `MOT_DE_PASSE_REFUSE`, +`CONNEXION_BLOQUEE`, `DECONNEXION`, `COMPTE_CREE`, `JETON_REGENERE`, `JETONS_GENERES_EN_LOT`, +`MOT_DE_PASSE_REINITIALISE`, `JOURNAL_PURGE`, `PLANNING_ENREGISTRE`, `PLANNING_VALIDE`, +`PLANNING_ROUVERT`, `CRENEAU_MODIFIE`, `CRENEAU_SUPPRIME`, `SEMAINE_COPIEE`, +`SEMAINE_TYPE_APPLIQUEE_PERIODE`, `AGENT_CREE`, `AGENT_MODIFIE`, `STRUCTURE_CREEE`, +`COUVERTURE_ENREGISTREE`, `VACANCES_ENREGISTREES`, `EQUIPE_CREEE`, `CSRF_INVALIDE`, +`ROLE_HORS_PERIMETRE`, `COMPTE_HORS_PERIMETRE`. + +--- + +## 7. Sécurité — contraintes à respecter + +- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`. + Tout `