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'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user