update
This commit is contained in:
15
CLAUDE.md
15
CLAUDE.md
@@ -139,6 +139,15 @@ et haché par `password_hash()`. La règle « qui doit avoir un mot de passe »
|
||||
qu'une attente horodatée de cinq minutes : **un jeton validé sans mot de passe n'ouvre rien**. La DSI
|
||||
peut effacer un mot de passe, jamais en poser un — sans quoi elle connaîtrait celui de tout le monde.
|
||||
|
||||
Depuis la V7.27 (`README_V7_27.md`), le Service Enfance distribue lui-même les jetons des rôles
|
||||
`AGENT` et `RESPONSABLE_STRUCTURE`, depuis l'onglet « Administration »
|
||||
(`AccessAccountController`, `api/acces_*.php`). **La délégation s'arrête à ces deux rôles** : un
|
||||
compte `SERVICE_ENFANCE` créé par le Service Enfance serait une auto-promotion, un compte `DSI` lui
|
||||
ouvrirait le journal de ses propres actions. La borne est posée des deux côtés —
|
||||
`AccessAccountController::ROLES_DELEGUES` en PHP, `can_manage_field_access` en base — et ne doit être
|
||||
élargie ni d'un côté ni de l'autre. La DSI reste seule sur les comptes privilégiés, les mots de passe
|
||||
et le journal.
|
||||
|
||||
`Access` relit le compte à **chaque requête** (`v_acces_compte`, filtrée sur `actif` et
|
||||
`identite_active`) : c'est ce qui fait qu'un jeton révoqué, un agent désactivé ou un lieu fermé
|
||||
referment la session en cours. Ne pas mettre ce résultat en cache entre deux requêtes.
|
||||
@@ -242,6 +251,12 @@ les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
|
||||
- Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient
|
||||
des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide
|
||||
de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas).
|
||||
- **Un compte d'accès coupé retire l'agent des listes d'AFFECTATION** (V7.29) : sélecteur de la
|
||||
saisie (`AgentModel::listForLocation()`) et composition des équipes (`allSelectable()`). Un agent
|
||||
**sans compte** reste proposé — c'est l'état normal avant distribution des jetons, le filtrer
|
||||
viderait les listes d'une installation neuve. Les écrans de consultation et le référentiel
|
||||
« Agents » continuent de le montrer : c'est de là qu'on lui rétablit son accès. `allActive()` reste
|
||||
la liste non filtrée ; ne pas confondre les deux.
|
||||
- Un agent peut être planifié sur plusieurs lieux la même semaine ; `id_structure` sur l'agent n'est
|
||||
qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures.
|
||||
- Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ;
|
||||
|
||||
20
api/acces_compte_creer.php
Normal file
20
api/acces_compte_creer.php
Normal 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();
|
||||
19
api/acces_compte_jeton.php
Normal file
19
api/acces_compte_jeton.php
Normal 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();
|
||||
19
api/acces_compte_statut.php
Normal file
19
api/acces_compte_statut.php
Normal 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
20
api/acces_comptes.php
Normal 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();
|
||||
19
api/acces_comptes_manquants.php
Normal file
19
api/acces_comptes_manquants.php
Normal 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();
|
||||
366
app/Controllers/AccessAccountController.php
Normal file
366
app/Controllers/AccessAccountController.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
345
assets/js/access-tokens.js
Normal file
345
assets/js/access-tokens.js
Normal 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 };
|
||||
})();
|
||||
@@ -252,7 +252,10 @@
|
||||
<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-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);
|
||||
row.innerHTML = agentRowHtml(agent);
|
||||
body.appendChild(row);
|
||||
// L'agent vient de naître : il n'a pas encore de compte d'accès. Le
|
||||
// module le dira sur la ligne, plutôt que d'y laisser un bouton
|
||||
// inerte au libellé provisoire.
|
||||
PTA.modules.agentAccess?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
158
assets/js/agent-access.js
Normal file
158
assets/js/agent-access.js
Normal 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 };
|
||||
})();
|
||||
@@ -54,7 +54,18 @@
|
||||
}
|
||||
if (params.agentId && agentSelect) {
|
||||
agentSelect.value = String(params.agentId);
|
||||
if (weekInput) weekInput.disabled = false;
|
||||
// Depuis la V7.29, un agent dont le compte d'accès est coupé sort des
|
||||
// listes d'affectation. Un lien profond qui le désigne (file de
|
||||
// validation, tableau de bord, vue annuelle) ne trouve alors aucune
|
||||
// option : sans ce message, l'écran resterait vide sans rien dire.
|
||||
if (agentSelect.value !== String(params.agentId)) {
|
||||
PTA.utils.showMessage(
|
||||
'Cet agent n’est plus proposé à la saisie : son accès a été coupé. Rétablissez-le depuis l’onglet Agents pour reprendre son planning.',
|
||||
'warning'
|
||||
);
|
||||
} else if (weekInput) {
|
||||
weekInput.disabled = false;
|
||||
}
|
||||
}
|
||||
if (params.week && weekInput) {
|
||||
weekInput.disabled = !agentSelect?.value;
|
||||
|
||||
@@ -3394,3 +3394,17 @@ input.field-invalid {
|
||||
input.field-valid {
|
||||
border-color: #1b7f3b;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
V7.28 - Actions de ligne sur la fiche des agents
|
||||
============================================================ */
|
||||
.agent-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-row-actions .button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
56
database/migration_v7_9h_jetons_service_enfance.sql
Normal file
56
database/migration_v7_9h_jetons_service_enfance.sql
Normal 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);
|
||||
1287
docs/README.md
1287
docs/README.md
File diff suppressed because it is too large
Load Diff
@@ -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
121
docs/README_V7_27.md
Normal 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.
|
||||
Reference in New Issue
Block a user