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

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

View File

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

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

View File

@@ -51,10 +51,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="assets/js/core.js?v=7.26"></script>
<script src="assets/js/core.js?v=7.29"></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; ?>
<script src="assets/js/page-bootstrap.js?v=7.26"></script>
<script src="assets/js/page-bootstrap.js?v=7.29"></script>
</body>
</html>

View File

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

View File

@@ -43,7 +43,7 @@ $titre = $titres[$etape] ?? 'Connexion';
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>
<body class="role-page-body">
<main class="role-page-shell">

View File

@@ -1,7 +1,8 @@
<?php
/**
* Administration : horaires de couverture attendus des lieux.
* Module JS : `coverage`.
* Administration : horaires de couverture attendus des lieux, et jetons d'accès
* 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
* 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
* décrivent ni une personne ni un lieu mais une EXIGENCE DE PRÉSENCE — le
* 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">
@@ -43,4 +51,113 @@
<button id="save-coverage-rules" class="button button-primary" type="button" disabled>Enregistrer les horaires de couverture</button>
</div>
</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>

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

View File

@@ -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 @@
<div class="section-heading">
<div>
<h2>Informations des agents</h2>
<p class="section-subtitle">Consultez la qualification, les coordonnées et le lieu principal de chaque agent. Le bouton Modifier ouvre la fiche complète.</p>
<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 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-contract-start"><?= htmlspecialchars((string) (($agent['date_debut_contrat'] ?? '') ?: 'Non renseignée')) ?></td>
<td class="agent-row-structure"><?= htmlspecialchars((string) (($agent['structure_nom'] ?? '') ?: 'Sans lieu principal')) ?></td>
<td><button type="button" class="button button-secondary edit-agent-profile">Modifier</button></td>
<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>
<?php endforeach; ?>
</tbody>