update
This commit is contained in:
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>
|
||||
|
||||
Reference in New Issue
Block a user