This commit is contained in:
PTA
2026-08-25 15:12:11 +02:00
parent 44730eefca
commit 1a67080268
25 changed files with 2646 additions and 231 deletions

View File

@@ -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 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. 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 `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é `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. 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 - 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 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). 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 - 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. 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 ; - Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ;

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — crée un compte de terrain et renvoie son jeton, affiché une seule fois.
* Charge utile : role (AGENT | RESPONSABLE_STRUCTURE), agent_id, structure_id,
* commentaire, 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\AccessAccountController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AccessAccountController($pdo))->creerCompte();

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 de terrain ; l'ancien est révoqué.
* 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\AccessAccountController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AccessAccountController($pdo))->regenererJeton();

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 de terrain.
* 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\AccessAccountController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AccessAccountController($pdo))->changerStatutCompte();

20
api/acces_comptes.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — comptes d'accès des agents et des responsables de lieu (Service Enfance).
* Les comptes Service Enfance et DSI n'y figurent jamais.
* 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\AccessAccountController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AccessAccountController($pdo))->comptes();

View File

@@ -0,0 +1,19 @@
<?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 qui n'en a pas.
* Charge utile : 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\AccessAccountController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AccessAccountController($pdo))->genererJetonsManquants();

View File

@@ -0,0 +1,366 @@
<?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 DomainException;
use PDO;
/**
* Jetons d'accès de terrain (V7.27) : le Service Enfance équipe ses agents et
* ses responsables de lieu.
*
* POURQUOI CE CONTRÔLEUR EXISTE À CÔTÉ DE DsiController : c'est le Service
* Enfance qui sait qu'un agent est arrivé, qu'un responsable a perdu son jeton
* ou qu'un remplaçant s'en va. Passer par la DSI pour chaque mouvement ajoutait
* un délai sans ajouter de contrôle. La DSI conserve tout : ses propres comptes,
* ceux du Service Enfance, le journal, la purge.
*
* LA FRONTIÈRE DE PRIVILÈGE, à ne jamais élargir :
*
* - self::ROLES_DELEGUES ne contient QUE AGENT et RESPONSABLE_STRUCTURE.
* Y ajouter SERVICE_ENFANCE ferait du rôle son propre distributeur — une
* auto-promotion sans contrôle externe ; y ajouter DSI lui ouvrirait le
* journal, c'est-à-dire la trace de ses propres actions ;
* - toute méthode qui désigne un compte existant passe par
* compteDelegue() AVANT d'agir. Un identifiant de compte privilégié est
* refusé en 403, jamais silencieusement ignoré ;
* - la liste blanche est doublée en base
* (database/migration_v7_9h_jetons_service_enfance.sql) : une frontière de
* privilège ne doit pas dépendre d'un seul des deux côtés.
*
* Les deux règles de DsiController restent valables ici :
* 1. un jeton en clair ne sort qu'AU MOMENT où il vient d'être produit ;
* 2. toute action est journalisée en catégorie ADMINISTRATION, sans jamais
* recopier le jeton dans la trace.
*/
final class AccessAccountController
{
/**
* Les seuls rôles dont le Service Enfance distribue les jetons.
* Lire l'en-tête de classe avant d'y toucher.
*/
private const ROLES_DELEGUES = [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE];
private Access $access;
private CompteModel $comptes;
public function __construct(private PDO $pdo)
{
$this->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<string, mixed> 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<int, array{code: string, libelle: string, identite: string}>
*/
private function rolesDelegues(): array
{
return [
['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'],
['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'],
];
}
}

View File

@@ -223,7 +223,9 @@ final class PageController extends Controller
$this->access->requirePage('teams'); $this->access->requirePage('teams');
$this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [ $this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [
'pageSubtitle' => 'La composition des équipes sur les mercredis et les périodes de vacances.', '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(), 'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['teams.js'], 'scripts' => ['teams.js'],
'modules' => ['teams'], 'modules' => ['teams'],
@@ -254,13 +256,16 @@ final class PageController extends Controller
{ {
$this->access->requirePage('administration'); $this->access->requirePage('administration');
$this->renderPage('administration', 'Administration', '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.', '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 que les règles de // Depuis la V7.14, cet écran ne porte plus les agents ni les lieux :
// couverture : agents et lieux ont leur propre page (management). // ils ont leur propre page (management). Seule la liste des lieux
// Seule la liste des lieux reste nécessaire, pour son sélecteur. // reste nécessaire, pour son sélecteur.
'structures' => (new StructureModel($this->pdo))->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['coverage.js'], // V7.27 : la distribution des jetons de terrain rejoint cet écran.
'modules' => ['coverage'], // 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(), 'agents' => $agentModel->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(),
'posts' => $agentModel->allPosts(), 'posts' => $agentModel->allPosts(),
'scripts' => ['administration.js', 'address-autocomplete.js', 'field-format.js'], // V7.28 : `agentAccess` ajoute sur chaque ligne le bouton qui coupe
'modules' => ['administration', 'addressAutocomplete', 'fieldFormat'], // 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'],
]); ]);
} }

View File

@@ -221,7 +221,37 @@ final class AgentModel extends BaseModel
SQL; 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<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
@@ -232,6 +262,19 @@ final class AgentModel extends BaseModel
)->fetchAll(); )->fetchAll();
} }
/**
* Agents proposables dans une liste d'affectation : actifs ET dont le compte
* d'accès n'a pas été coupé.
*
* @return array<int, array<string, mixed>>
*/
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 * Agents visibles depuis un lieu : ceux qui y sont RATTACHÉS, plus ceux qui
* y ont un planning, quel que soit leur rattachement. * y ont un planning, quel que soit leur rattachement.
@@ -328,7 +371,7 @@ final class AgentModel extends BaseModel
FROM agent a FROM agent a
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure 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" ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom"
); );
$stmt->execute(['selected_location' => $selectedLocation]); $stmt->execute(['selected_location' => $selectedLocation]);

View File

@@ -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<string, mixed> $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<string, mixed> $filtres role, actif (0|1), q (texte libre),
* roles (array<int, string> facultatif).
* *
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
@@ -136,10 +143,27 @@ final class CompteModel extends BaseModel
$conditions = []; $conditions = [];
$params = []; $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'] ?? ''); $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'; $conditions[] = 'role = :role';
$params['role'] = $role; $params['role'] = $role;
} elseif ($role !== '') {
$conditions[] = '1 = 0';
} }
if (isset($filtres['actif']) && $filtres['actif'] !== '') { if (isset($filtres['actif']) && $filtres['actif'] !== '') {
@@ -149,8 +173,18 @@ final class CompteModel extends BaseModel
$recherche = trim((string) ($filtres['q'] ?? '')); $recherche = trim((string) ($filtres['q'] ?? ''));
if ($recherche !== '') { if ($recherche !== '') {
$conditions[] = '(identite_libelle LIKE :q OR libelle LIKE :q OR matricule LIKE :q OR jeton_prefixe LIKE :q)'; // UN MARQUEUR PAR COLONNE, et non un seul répété quatre fois : PDO
$params['q'] = '%' . $recherche . '%'; // 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); $where = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions);

View File

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

View File

@@ -104,7 +104,7 @@ $showHelpTab = in_array('faq', $allowedPages, true);
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title> <title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.26"> <link rel="stylesheet" href="assets/style.css?v=7.29">
</head> </head>
<body> <body>
<main class="app-shell"> <main class="app-shell">

View File

@@ -43,7 +43,7 @@ $titre = $titres[$etape] ?? 'Connexion';
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($titre) ?> — PTA</title> <title><?= htmlspecialchars($titre) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.26"> <link rel="stylesheet" href="assets/style.css?v=7.29">
</head> </head>
<body class="role-page-body"> <body class="role-page-body">
<main class="role-page-shell"> <main class="role-page-shell">

View File

@@ -1,7 +1,8 @@
<?php <?php
/** /**
* Administration : horaires de couverture attendus des lieux. * Administration : horaires de couverture attendus des lieux, et jetons d'accès
* Module JS : `coverage`. * des agents et des responsables de lieu.
* Modules JS : `coverage` et `accessTokens`.
* *
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML * 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 * posés ici sont les points d'ancrage du module JS : les renommer casse le
@@ -11,6 +12,13 @@
* page « Management ». Ne restent ici que les règles de couverture, qui ne * 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 * décrivent ni une personne ni un lieu mais une EXIGENCE DE PRÉSENCE — le
* paramètre que contrôle l'onglet « Couverture ». * paramètre que contrôle l'onglet « Couverture ».
*
* V7.27 : s'y ajoute la distribution des JETONS DE TERRAIN. Le Service Enfance
* équipe ses agents et ses responsables de lieu sans passer par la DSI, mais
* RIEN AU-DELÀ : les comptes Service Enfance et DSI n'apparaissent pas ici et
* sont refusés par le serveur (AccessAccountController). Les classes `dsi-*`
* sont réutilisées telles quelles — elles ne portent qu'une mise en forme, et
* les deux écrans montrent la même chose : des comptes et des jetons.
*/ */
?> ?>
<section id="assignment-view" class="app-view page-content"> <section id="assignment-view" class="app-view page-content">
@@ -43,4 +51,113 @@
<button id="save-coverage-rules" class="button button-primary" type="button" disabled>Enregistrer les horaires de couverture</button> <button id="save-coverage-rules" class="button button-primary" type="button" disabled>Enregistrer les horaires de couverture</button>
</div> </div>
</section> </section>
<!--
Jetons d'accès de terrain (V7.27).
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 qu'il n'a pas été explicitement refermé, pour laisser le temps
de les recopier ou de les imprimer.
-->
<section id="access-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, remettez-les à leurs titulaires, puis refermez ce panneau.</p>
</div>
<div class="button-group">
<button id="access-token-copy" class="button button-secondary" type="button">Tout copier</button>
<button id="access-token-close" class="button button-secondary" type="button">Fermer</button>
</div>
</div>
<div id="access-token-list" class="dsi-token-list"></div>
<div id="access-token-message" class="message" role="status" aria-live="polite"></div>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Jetons d’accès des agents et des responsables</h2>
<p class="section-subtitle">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.</p>
</div>
<button id="access-generate-missing" class="button button-secondary" type="button">
Générer les jetons manquants <span id="access-missing-count" class="nav-count-badge" hidden>0</span>
</button>
</div>
<form id="access-account-form" class="admin-form">
<div class="admin-form-grid">
<label class="field">
<span>Type de compte</span>
<select id="access-account-role" required>
<option value="AGENT">Agent</option>
<option value="RESPONSABLE_STRUCTURE">Responsable de structure</option>
</select>
</label>
<label class="field" id="access-account-agent-field">
<span>Agent titulaire</span>
<select id="access-account-agent"><option value="">Choisir un agent sans compte</option></select>
</label>
<label class="field" id="access-account-structure-field" hidden>
<span>Lieu titulaire</span>
<select id="access-account-structure"><option value="">Choisir un lieu sans compte</option></select>
</label>
<label class="field admin-field-wide">
<span>Commentaire (facultatif)</span>
<input id="access-account-comment" type="text" maxlength="255" placeholder="Ex. remplacement congé maternité, jusqu’en septembre">
</label>
</div>
<p id="access-account-hint" class="hint"></p>
<div class="admin-form-actions">
<div id="access-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="access-filter-role">
<option value="">Agents et responsables</option>
<option value="AGENT">Agents</option>
<option value="RESPONSABLE_STRUCTURE">Responsables de structure</option>
</select>
</label>
<label class="field">
<span>État</span>
<select id="access-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="access-filter-search" type="search" maxlength="80" placeholder="Nom, matricule ou début de jeton">
</label>
<div class="overview-filter-actions">
<button id="access-accounts-refresh" class="button button-secondary filter-button" type="button">Actualiser</button>
</div>
</div>
<div id="access-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="access-accounts-body">
<tr><td colspan="6">Chargement des comptes…</td></tr>
</tbody>
</table>
</div>
</section>
</section> </section>

View File

@@ -155,6 +155,8 @@ $sommaire = [
<li><strong>Adresse personnelle</strong> : 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.</li> <li><strong>Adresse personnelle</strong> : 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.</li>
<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><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> <li>Cliquez sur <strong>Ajouter l’agent</strong>.</li>
<li>Pour <strong>couper l’accès</strong> d’un agent (départ, arrêt long) sans supprimer sa fiche : bouton <strong>Couper l’accès</strong> sur sa ligne, à côté de Modifier. Son jeton reste valable et resservira tel quel au rétablissement. L’agent <strong>disparaît alors des listes d’affectation</strong> — saisie des plannings et composition des équipes — mais reste consultable, et ses créneaux déjà saisis sont conservés.</li>
<li>Enfin, donnez-lui son <strong>jeton d’accès</strong> : onglet <strong>Administration</strong>, encadré « Jetons d’accès des agents et des responsables ». Le jeton n’est affiché <em>qu’une seule fois</em> — 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.</li>
</ol> </ol>
<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"><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> <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>

View File

@@ -20,6 +20,11 @@
* champ + `[data-address-suggestions]` + `[data-address-status]` — mais aucun * champ + `[data-address-suggestions]` + `[data-address-status]` — mais aucun
* identifiant n'est connu du module. * 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 * 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` * 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 * à contrôler la saisie. Ce contrôle ne fait que PRÉVENIR : c'est
@@ -140,7 +145,7 @@
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>Informations des agents</h2> <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> <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 ; le second bouton coupe ou rétablit l’accès de l’agent à l’application, sans toucher à son jeton.</p>
</div> </div>
</div> </div>
<div id="assignment-message" class="message" role="status" aria-live="polite"></div> <div id="assignment-message" class="message" role="status" aria-live="polite"></div>
@@ -188,7 +193,15 @@
<td class="agent-row-phone"><?= htmlspecialchars((string) (($agent['telephone'] ?? '') ?: 'Non renseigné')) ?></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-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 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> <td class="agent-row-actions">
<button type="button" class="button button-secondary edit-agent-profile">Modifier</button>
<!--
État du compte d'accès rempli par le module `agentAccess`
après appel de l'API : le HTML servi ne porte aucune
information sur les comptes, comme sur l'écran DSI.
-->
<button type="button" class="button button-secondary agent-access-toggle" disabled>Compte d’accès…</button>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>

345
assets/js/access-tokens.js Normal file
View File

@@ -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) => `
<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('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 = [
`<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>');
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 access-regenerate">Régénérer</button>
<button type="button" class="button button-secondary access-toggle">${compte.actif ? 'Désactiver' : 'Réactiver'}</button>
<button type="button" class="button button-danger access-delete">Supprimer</button>
</td>
</tr>`;
}
/**
* 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 = '<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('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('')
: '<tr><td colspan="6">Aucun compte ne correspond à ces filtres.</td></tr>';
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 };
})();

View File

@@ -252,7 +252,10 @@
<td class="agent-row-phone">${escapeHtml(agent.telephone || 'Non renseigné')}</td> <td class="agent-row-phone">${escapeHtml(agent.telephone || 'Non renseigné')}</td>
<td class="agent-row-contract-start">${escapeHtml(formatContractDate(agent.date_debut_contrat))}</td> <td class="agent-row-contract-start">${escapeHtml(formatContractDate(agent.date_debut_contrat))}</td>
<td class="agent-row-structure">${escapeHtml(structureLabel)}</td> <td class="agent-row-structure">${escapeHtml(structureLabel)}</td>
<td><button type="button" class="button button-secondary edit-agent-profile">Modifier</button></td> <td class="agent-row-actions">
<button type="button" class="button button-secondary edit-agent-profile">Modifier</button>
<button type="button" class="button button-secondary agent-access-toggle" disabled>Compte d’accès…</button>
</td>
`; `;
} }
@@ -301,6 +304,10 @@
setAgentRowData(row, agent); setAgentRowData(row, agent);
row.innerHTML = agentRowHtml(agent); row.innerHTML = agentRowHtml(agent);
body.appendChild(row); 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?.();
} }
} }

158
assets/js/agent-access.js Normal file
View File

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

View File

@@ -54,7 +54,18 @@
} }
if (params.agentId && agentSelect) { if (params.agentId && agentSelect) {
agentSelect.value = String(params.agentId); 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) { if (params.week && weekInput) {
weekInput.disabled = !agentSelect?.value; weekInput.disabled = !agentSelect?.value;

View File

@@ -3394,3 +3394,17 @@ input.field-invalid {
input.field-valid { input.field-valid {
border-color: #1b7f3b; 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;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,129 +0,0 @@
# PTA - Docker / Nginx / PHP / MariaDB / phpMyAdmin
## Installation
Copier les fichiers de ce kit à la racine du projet PTA :
- `Dockerfile`
- `docker-compose.yml`
- `.env.example`
- `docker/nginx/default.conf`
- `docker/php/php.ini`
- `docker/db/apply-migrations.sh`
- `database/pta_prod_schema_mariadb.sql`
- `database/migration_*.sql`
Puis :
```bash
cp .env.example .env
nano .env
```
Changer impérativement `DB_PASSWORD` et `DB_ROOT_PASSWORD`.
Démarrer :
```bash
docker compose up -d --build
```
Application :
```text
http://IP_DU_SERVEUR:8080
```
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut et **exige
un identifiant MariaDB** à chaque connexion. Ne pas définir `PMA_USER` /
`PMA_PASSWORD` dans le compose : ces variables font basculer phpMyAdmin en
`auth_type=config`, c'est-à-dire une session déjà ouverte pour quiconque
atteint le port.
Sur le serveur lui-même :
```text
http://127.0.0.1:8081
```
Pour y accéder depuis un poste distant, le plus sûr est un tunnel SSH :
```bash
ssh -L 8081:127.0.0.1:8081 utilisateur@serveur
```
Puis ouvrir localement :
```text
http://127.0.0.1:8081
```
## Base de données
Le schéma `database/pta_prod_schema_mariadb.sql` est exécuté automatiquement uniquement
lorsque le volume MariaDB est créé pour la première fois.
Il ne sera PAS rejoué à chaque démarrage du conteneur.
### Migrations
Les fichiers `database/migration_*.sql` sont appliqués **automatiquement à chaque
`docker compose up`**, par le service jetable `db-migrate` :
- il attend que MariaDB soit sain, puis joue les migrations dans l'ordre alphabétique ;
- la table `schema_migration` mémorise les fichiers déjà appliqués — rien n'est rejoué
inutilement ;
- le service `php` ne démarre qu'après sa sortie en succès : une migration en échec arrête
le déploiement au lieu de faire tourner l'application sur une base incomplète ;
- il vérifie pour finir la présence des sept vues de contrôle d'accès de la V7.8.
Il n'est donc plus nécessaire de supprimer le volume ni d'exécuter les migrations à la main.
```bash
# Compte rendu de la dernière application
docker compose logs db-migrate
# Rejouer les migrations sans toucher au reste de la pile
docker compose up -d --force-recreate db-migrate
```
Toute nouvelle migration doit être écrite idempotente (`CREATE TABLE IF NOT EXISTS`,
`INSERT ... ON DUPLICATE KEY UPDATE`, `CREATE OR REPLACE VIEW`) : un fichier dont le contenu
change après coup est rejoué.
## Commandes utiles
```bash
# Etat
docker compose ps
# Logs
docker compose logs -f nginx php db
# Rebuild après modification du PHP / JS / CSS
docker compose up -d --build php nginx
# Journal de l'application des migrations
docker compose logs db-migrate
# Arrêt
docker compose down
# Arrêt SANS supprimer les données
docker compose down
# ATTENTION : supprime la base MariaDB persistée
docker compose down -v
```
## Connexion de l'application
Le `config.php` PTA utilise déjà les variables :
- `PTA_DB_HOST`
- `PTA_DB_PORT`
- `PTA_DB_NAME`
- `PTA_DB_USER`
- `PTA_DB_PASS`
Le compose les fournit automatiquement au conteneur PHP.

121
docs/README_V7_27.md Normal file
View File

@@ -0,0 +1,121 @@
# PTA V7.27 — Le Service Enfance distribue les jetons des agents et des responsables
## Le problème traité
Depuis la V7.11, tout compte d’accès passait par la DSI : création, jeton perdu, départ d’un
remplaçant. Or c’est le **Service Enfance** qui sait qu’un agent est arrivé, qu’un responsable a
laissé son jeton chez lui ou qu’un contrat s’arrête. Le détour par la DSI ajoutait un délai — parfois
plusieurs jours — sans ajouter le moindre contrôle : la DSI ne pouvait rien vérifier de ce qu’on lui
demandait, elle exécutait.
## Ce qui change
L’onglet **Administration** du Service Enfance gagne une section « Jetons d’accès des agents et des
responsables », sous les horaires de couverture. On y fait, pour ces deux rôles, ce que la page DSI
permet :
- **créer** un compte pour un agent ou un lieu qui n’en a pas encore ;
- **régénérer** un jeton perdu ou compromis — l’ancien cesse aussitôt de fonctionner ;
- **désactiver** puis réactiver un compte (départ temporaire) ;
- **supprimer** un compte définitivement ;
- **générer en lot** les jetons de toutes les identités actives qui n’en ont pas.
Comme sur la page DSI, un jeton produit n’est lisible **qu’une fois**, dans le panneau « Jetons à
transmettre » : la base n’en garde qu’une empreinte SHA-256. Perdu, il ne se retrouve pas — il se
régénère.
## Ce qui ne change pas : la frontière de privilège
La délégation s’arrête à **deux rôles : AGENT et RESPONSABLE_STRUCTURE**. Les comptes
**SERVICE_ENFANCE** et **DSI** n’apparaissent pas dans cette section, ne sont pas créables, et toute
action qui en désigne un est refusée en **403**. 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 extérieur ;
- un compte DSI créé par le Service Enfance lui ouvrirait le **journal d’activité**, c’est-à-dire la
trace de ses propres actions.
Le **second facteur** de la V7.12 reste hors de portée : ces deux rôles n’ont pas de mot de passe, et
le Service Enfance n’a aucun moyen d’en effacer un. La réinitialisation de mot de passe reste une
action DSI, sur la page DSI.
La borne est posée **des deux côtés** :
| Où | Quoi |
| --- | --- |
| `AccessAccountController::ROLES_DELEGUES` | La liste blanche PHP, contrôlée avant toute écriture et à la relecture de tout compte désigné |
| `CompteModel::lister(['roles' => …])` | La liste SQL est bornée ; le filtre d’interface ne peut que **restreindre** à l’intérieur de cette borne |
| `migration_v7_9h_jetons_service_enfance.sql` | La permission `can_manage_field_access`, accordée au seul Service Enfance |
Une frontière de privilège ne doit pas dépendre d’un seul des deux côtés : une ligne mal saisie en
base ne doit pas pouvoir ouvrir la création de comptes privilégiés, et inversement.
## Traçabilité
Toutes les actions sont journalisées en catégorie `ADMINISTRATION`, avec le rôle de l’acteur et un
contexte `canal = DELEGATION_SERVICE_ENFANCE` qui les distingue des mêmes actions faites par la DSI.
Les **refus** le sont aussi, en `ACCES_REFUSE` : `ROLE_HORS_PERIMETRE` (création tentée sur un rôle
privilégié) et `COMPTE_HORS_PERIMETRE` (action tentée sur un compte privilégié). La DSI garde donc la
lecture complète de ce que le Service Enfance fait des accès.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `database/migration_v7_9h_jetons_service_enfance.sql` | Permission `can_manage_field_access`. Aucune page nouvelle : « Administration » est déjà ouverte au Service Enfance |
| `app/Controllers/AccessAccountController.php` | Les cinq endpoints, la liste blanche et les refus |
| `api/acces_comptes.php`, `acces_compte_creer.php`, `acces_compte_jeton.php`, `acces_compte_statut.php`, `acces_comptes_manquants.php` | Points d’entrée minces |
| `app/Models/CompteModel.php` | Filtre `roles` sur `lister()` |
| `app/Views/partials/administration.php` | La section et le panneau des jetons |
| `assets/js/access-tokens.js` | Module `accessTokens` |
## V7.28 — Couper l’accès depuis la fiche des agents
La page **Agents** gagne, à côté de « Modifier », un bouton qui **coupe ou rétablit l’accès** de
l’agent : « Couper l’accès » / « Rétablir l’accès ». C’est le geste courant — un départ, un arrêt
long, un retour — là où la section « Jetons d’accès » de l’onglet Administration porte la
distribution des jetons elle-même.
Ce bouton **ne touche pas au jeton** : désactiver puis réactiver rend le *même* jeton valable, et
l’agent n’a rien à recevoir. Il ne touche pas non plus à la fiche de l’agent, qui reste planifiable —
c’est l’accès à l’application qui est coupé, pas la personne au planning. Un agent qui n’a pas encore
de compte affiche un bouton inerte « Sans compte d’accès », qui renvoie vers Administration : seul
cet écran sait afficher un jeton fraîchement produit.
Aucun droit nouveau : le module `agentAccess` réutilise `api/acces_comptes.php` et
`api/acces_compte_statut.php`, donc les mêmes gardes, la même liste blanche de rôles et la même
journalisation. L’état des comptes n’est **pas** rendu par PHP mais chargé par le module — le HTML
servi ne porte aucune information sur les comptes, comme sur l’écran DSI.
## V7.29 — Un accès coupé retire l’agent des listes d’affectation
Couper l’accès d’un agent le retire désormais des listes **où l’on affecte** :
| Écran | Effet |
| --- | --- |
| Saisie (`api/agents.php` → `AgentModel::listForLocation()`) | L’agent n’est plus proposé dans le sélecteur |
| Équipes (`PageController::teams()` → `allSelectable()`) | L’agent n’est plus proposé à la composition |
Trois bornes, toutes portées par le même `NOT EXISTS` de `AgentModel::SANS_COMPTE_COUPE` :
- **un agent SANS compte reste proposé.** Il n’a 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` : fermer une fiche d’agent et
couper un accès restent deux décisions distinctes ;
- 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 relire son
année passée, ni lui rétablir son accès — le bouton vit précisément sur cette page.
**Ce qui n’est pas défait :** les créneaux déjà saisis et les équipes déjà composées restent en
place. Couper un accès n’efface rien du passé. En revanche, un lien profond qui désigne un agent
coupé (file de validation, tableau de bord) ne peut plus le sélectionner : l’écran de saisie le dit
alors explicitement, plutôt que de rester vide — rétablir l’accès rend la saisie de nouveau possible.
## Correctif embarqué
`CompteModel::lister()` répétait le paramètre nommé `:q` quatre fois dans sa clause de recherche. PDO
tourne ici **sans émulation de requêtes préparées** (`ATTR_EMULATE_PREPARES = 0`) : MariaDB refuse
alors un même paramètre employé plusieurs fois. **Toute recherche par nom, matricule ou début de
jeton échouait en 500 sur la page DSI**, depuis la V7.11. Les marqueurs sont désormais numérotés
(`:q_0` … `:q_3`). Le même champ de recherche existant dans la nouvelle section, le défaut aurait été
reconduit tel quel.