update
This commit is contained in:
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\PdfResponse;
|
||||
use App\Core\Request;
|
||||
@@ -414,6 +415,16 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'AGENT_RATTACHEMENT_MODIFIE',
|
||||
'agent',
|
||||
(int) $agentId,
|
||||
$structureId === null
|
||||
? sprintf('Lieu principal retiré à l’agent #%d.', (int) $agentId)
|
||||
: sprintf('Lieu principal de l’agent #%d changé pour le lieu #%d.', (int) $agentId, (int) $structureId),
|
||||
['id_structure' => $structureId !== null ? (int) $structureId : null]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => $structureId === null
|
||||
? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.'
|
||||
@@ -518,6 +529,14 @@ final class AgentController
|
||||
}
|
||||
|
||||
$structure = $this->structureModel->findActive((int) $structureId);
|
||||
Journal::ecriture(
|
||||
'AGENT_CREE',
|
||||
'agent',
|
||||
isset($created['id_agent']) ? (int) $created['id_agent'] : null,
|
||||
sprintf('Création de l’agent %s %s (matricule %s).', $prenom, $nom, $matricule),
|
||||
['matricule' => $matricule, 'id_structure' => (int) $structureId, 'type_contrat' => $contractType]
|
||||
);
|
||||
|
||||
JsonResponse::send(201, [
|
||||
'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']),
|
||||
'agent' => array_merge($created, [
|
||||
@@ -548,7 +567,9 @@ final class AgentController
|
||||
* POST api/update_agent.php — modification de la fiche d'un agent.
|
||||
*
|
||||
* Mêmes contrôles que create(), matricule excepté : celui-ci n'est jamais
|
||||
* modifiable, c'est la clé de rapprochement avec les outils RH.
|
||||
* modifiable, c'est la clé de rapprochement avec les outils RH. Le nom et le
|
||||
* prénom, EUX, le sont (V7.13) : mariage, divorce, ou simple faute de frappe
|
||||
* à la saisie initiale — sans cet écran, il fallait passer par SQL.
|
||||
*
|
||||
* La quotité de travail n'est pas modifiable ici non plus : elle détermine
|
||||
* le quota annuel déjà consommé, et la changer en cours d'année fausserait
|
||||
@@ -562,6 +583,8 @@ final class AgentController
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
$nom = trim((string) ($payload['nom'] ?? ''));
|
||||
$prenom = trim((string) ($payload['prenom'] ?? ''));
|
||||
$email = trim((string) ($payload['email'] ?? ''));
|
||||
$telephone = trim((string) ($payload['telephone'] ?? ''));
|
||||
$adresse = trim((string) ($payload['adresse'] ?? ''));
|
||||
@@ -586,9 +609,15 @@ final class AgentController
|
||||
if (!$agentId) {
|
||||
JsonResponse::send(422, ['error' => 'Agent invalide.']);
|
||||
}
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
// La fiche AVANT modification est conservée : elle sert à repérer un
|
||||
// renommage et à le nommer explicitement dans le journal.
|
||||
$agentAvant = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agentAvant === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
|
||||
}
|
||||
if ($nom === '' || $prenom === '') {
|
||||
JsonResponse::send(422, ['error' => 'Le nom et le prénom de l’agent sont obligatoires.']);
|
||||
}
|
||||
if (!$postId || !in_array((int) $postId, $validPostIds, true)
|
||||
|| !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true)
|
||||
|| !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart
|
||||
@@ -598,15 +627,43 @@ final class AgentController
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
|
||||
}
|
||||
if (strlen($email) > 255 || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
|
||||
if (strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255 || strlen($telephone) > 30
|
||||
|| strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
|
||||
JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
|
||||
}
|
||||
if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) {
|
||||
JsonResponse::send(422, ['error' => 'Lieu d’affectation principal invalide ou inactif.']);
|
||||
}
|
||||
|
||||
// Contrôle MIROIR de celui posé à l'affectation (V7.15) : interdire de
|
||||
// planifier hors contrat ne sert à rien si l'on peut ensuite déplacer le
|
||||
// contrat sous des affectations existantes. Le refus nomme la période
|
||||
// concernée, pour que l'utilisateur sache quoi retirer avant de
|
||||
// recommencer.
|
||||
$orphelins = $this->agentModel->entriesOutsideContract(
|
||||
(int) $agentId,
|
||||
$contractStart,
|
||||
$contractEnd !== '' ? $contractEnd : null
|
||||
);
|
||||
if ($orphelins['nombre'] > 0) {
|
||||
$lisible = static fn(?string $date): string => $date === null
|
||||
? '—'
|
||||
: implode('/', array_reverse(explode('-', $date)));
|
||||
JsonResponse::send(422, [
|
||||
'error' => sprintf(
|
||||
'Ces dates de contrat laisseraient %d affectation(s) en dehors du contrat, entre le %s et le %s. Supprimez-les d’abord, ou élargissez les dates.',
|
||||
$orphelins['nombre'],
|
||||
$lisible($orphelins['premiere']),
|
||||
$lisible($orphelins['derniere'])
|
||||
),
|
||||
'code' => 'HORS_CONTRAT',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->agentModel->updateProfile((int) $agentId, [
|
||||
'nom' => $nom,
|
||||
'prenom' => $prenom,
|
||||
'email' => $email !== '' ? $email : null,
|
||||
'telephone' => $telephone !== '' ? $telephone : null,
|
||||
'adresse' => $adresse !== '' ? $adresse : null,
|
||||
@@ -627,9 +684,33 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
// La fiche est relue une seule fois : elle sert à la fois au libellé de
|
||||
// la trace et à la réponse.
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
|
||||
$identiteAvant = trim((string) $agentAvant['prenom'] . ' ' . (string) $agentAvant['nom']);
|
||||
$identiteApres = trim($prenom . ' ' . $nom);
|
||||
$renomme = $identiteAvant !== $identiteApres;
|
||||
|
||||
Journal::ecriture(
|
||||
'AGENT_MODIFIE',
|
||||
'agent',
|
||||
(int) $agentId,
|
||||
$renomme
|
||||
// Un renommage doit se lire dans le journal sans avoir à
|
||||
// comparer deux lignes : l'ancienne identité y figure en clair.
|
||||
? sprintf('Modification de la fiche de l’agent %s, renommé depuis « %s ».', $identiteApres, $identiteAvant)
|
||||
: sprintf('Modification de la fiche de l’agent %s.', $identiteApres),
|
||||
[
|
||||
'id_structure' => $structureId !== null ? (int) $structureId : null,
|
||||
'type_contrat' => $contractType,
|
||||
'identite_precedente' => $renomme ? $identiteAvant : null,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Les informations de l’agent ont été mises à jour.',
|
||||
'agent' => $this->agentModel->findActive((int) $agentId),
|
||||
'agent' => $agent,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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\AnnualOverviewModel;
|
||||
@@ -164,6 +165,19 @@ final class AnnualOverviewController
|
||||
$ids = array_map(static fn(array $planning): int => (int) $planning['id_planning'], $plannings);
|
||||
$validatedCount = $planningModel->markValidatedMany($ids);
|
||||
|
||||
Journal::ecriture(
|
||||
'ANNEE_VALIDEE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf('%d planning(s) validés d’un coup pour l’agent #%d, année %d.', $validatedCount, (int) $agentId, (int) $year),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'annee' => (int) $year,
|
||||
'id_plannings' => $ids,
|
||||
'quota_force' => $forceQuota,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'status' => 'validated',
|
||||
@@ -211,6 +225,19 @@ final class AnnualOverviewController
|
||||
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
|
||||
}
|
||||
|
||||
foreach ($model->entriesOutsideContract($planningId) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
'ERREUR',
|
||||
'HORS_CONTRAT',
|
||||
sprintf(
|
||||
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
|
||||
$row['id_creneau'],
|
||||
$row['date_jour']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($model->entriesOutsideWeek($planningId, (string) $planning['date_debut'], (string) $planning['date_fin']) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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\CoverageModel;
|
||||
@@ -221,6 +222,14 @@ final class CoverageController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'COUVERTURE_ENREGISTREE',
|
||||
'structure_couverture_horaire',
|
||||
(int) $structureId,
|
||||
sprintf('Horaires de couverture du lieu #%d enregistrés : %d plage(s).', (int) $structureId, count($rules)),
|
||||
['id_structure' => (int) $structureId, 'nombre_plages' => count($rules)]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Les horaires de couverture du lieu ont été enregistrés.',
|
||||
'rules' => $this->coverageModel->rulesForStructure((int) $structureId),
|
||||
|
||||
394
app/Controllers/DsiController.php
Normal file
394
app/Controllers/DsiController.php
Normal file
@@ -0,0 +1,394 @@
|
||||
<?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 App\Models\JournalModel;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
|
||||
*
|
||||
* C'est de l'administration TECHNIQUE, pas fonctionnelle. La DSI distribue les
|
||||
* accès et lit les traces ; elle ne voit ni planning, ni quota, ni agent —
|
||||
* acces_role_page ne lui ouvre qu'une seule page.
|
||||
*
|
||||
* DEUX RÈGLES PROPRES À CE CONTRÔLEUR :
|
||||
* 1. un jeton en clair ne sort d'ici QU'AU MOMENT où il vient d'être produit
|
||||
* (création, régénération, génération en lot). Aucune lecture ne le
|
||||
* retourne, parce que la base ne l'a plus ;
|
||||
* 2. toute action y est journalisée en catégorie ADMINISTRATION, y compris la
|
||||
* purge du journal — et sans jamais recopier le jeton dans la trace.
|
||||
*/
|
||||
final class DsiController
|
||||
{
|
||||
private Access $access;
|
||||
private CompteModel $comptes;
|
||||
private JournalModel $journal;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->access = new Access($pdo);
|
||||
$this->comptes = new CompteModel($pdo);
|
||||
$this->journal = new JournalModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/dsi_comptes.php — comptes d'accès et identités sans compte.
|
||||
*
|
||||
* Paramètres GET : role, actif, q.
|
||||
*/
|
||||
public function comptes(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
|
||||
$comptes = $this->comptes->lister([
|
||||
'role' => (string) ($_GET['role'] ?? ''),
|
||||
'actif' => (string) ($_GET['actif'] ?? ''),
|
||||
'q' => (string) ($_GET['q'] ?? ''),
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'comptes' => $comptes,
|
||||
'manquants' => $this->comptes->manquants(),
|
||||
// Tant qu'un compte porte encore le jeton public de la migration,
|
||||
// l'interface doit le crier : c'est une porte ouverte documentée.
|
||||
'jeton_installation_actif' => $this->comptes->installationEnCours(),
|
||||
'roles' => $this->rolesDisponibles(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_creer.php — crée un compte et renvoie son jeton.
|
||||
*
|
||||
* Charge utile : role, agent_id, structure_id, libelle, commentaire,
|
||||
* csrf_token.
|
||||
*/
|
||||
public function creerCompte(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$role = strtoupper(trim((string) ($payload['role'] ?? '')));
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$libelle = trim((string) ($payload['libelle'] ?? ''));
|
||||
$commentaire = trim((string) ($payload['commentaire'] ?? ''));
|
||||
|
||||
try {
|
||||
$resultat = $this->comptes->creer(
|
||||
$role,
|
||||
$agentId !== null ? (int) $agentId : null,
|
||||
$structureId !== null ? (int) $structureId : null,
|
||||
$libelle,
|
||||
$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).',
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']],
|
||||
]);
|
||||
|
||||
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/dsi_compte_jeton.php — régénère le jeton d'un compte.
|
||||
*
|
||||
* L'ancien jeton cesse de fonctionner immédiatement : c'est la procédure à
|
||||
* suivre pour un jeton perdu comme pour un jeton compromis.
|
||||
*
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*/
|
||||
public function regenererJeton(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
|
||||
try {
|
||||
$jeton = $this->comptes->regenererJeton($compteId);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(404, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$compte = $this->comptes->trouver($compteId) ?? [];
|
||||
Journal::administration('JETON_REGENERE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'Régénération du jeton de « %s » : l’ancien jeton est révoqué.',
|
||||
(string) ($compte['identite_libelle'] ?? 'compte inconnu')
|
||||
),
|
||||
'contexte' => ['prefixe' => $compte['jeton_prefixe'] ?? null],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.',
|
||||
'compte' => $compte,
|
||||
'jeton' => $jeton,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_mot_de_passe.php — efface le mot de passe d'un compte
|
||||
* privilégié.
|
||||
*
|
||||
* La DSI ne REMPLACE pas le mot de passe, elle le RETIRE : le titulaire en
|
||||
* choisira un nouveau à sa prochaine connexion, avec son jeton. C'est ce qui
|
||||
* garantit que personne d'autre que lui ne l'a jamais connu — y compris la
|
||||
* personne qui vient de le réinitialiser.
|
||||
*
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*/
|
||||
public function reinitialiserMotDePasse(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
|
||||
try {
|
||||
$compte = $this->comptes->reinitialiserMotDePasse($compteId);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
Journal::administration('MOT_DE_PASSE_REINITIALISE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'Mot de passe de « %s » effacé : le titulaire en définira un nouveau à sa prochaine connexion.',
|
||||
(string) $compte['identite_libelle']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role']],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf(
|
||||
'Mot de passe effacé pour %s. Son jeton reste valable : il en choisira un nouveau à sa prochaine connexion.',
|
||||
(string) $compte['identite_libelle']
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_statut.php — active, désactive ou supprime un compte.
|
||||
*
|
||||
* La suppression efface le compte mais PAS ses traces : le journal conserve
|
||||
* le libellé figé de l'acteur (clé étrangère en ON DELETE SET NULL).
|
||||
*
|
||||
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER),
|
||||
* csrf_token.
|
||||
*/
|
||||
public function changerStatutCompte(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
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 : après une suppression, il n'y
|
||||
// aurait plus de libellé à écrire dans le journal.
|
||||
$compte = $this->comptes->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
JsonResponse::send(404, ['error' => 'Compte introuvable.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Se retirer soi-même son propre accès en pleine session est une erreur
|
||||
// de manipulation, jamais une intention : on la refuse.
|
||||
if ($compteId === $this->access->accountId() && $action !== 'ACTIVER') {
|
||||
JsonResponse::send(422, ['error' => 'Vous ne pouvez pas désactiver ni supprimer le compte avec lequel vous êtes connecté.']);
|
||||
}
|
||||
|
||||
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).',
|
||||
$libelles[$action],
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_comptes_manquants.php — équipe d'un coup toutes les identités
|
||||
* actives sans compte.
|
||||
*
|
||||
* C'est l'outil de MISE EN SERVICE : à l'installation, aucun agent ni
|
||||
* responsable n'a de jeton, donc personne ne peut entrer. La réponse porte
|
||||
* tous les jetons produits, en clair — c'est la seule occasion de les lire,
|
||||
* et elle est faite pour être imprimée ou copiée avant de refermer l'écran.
|
||||
*
|
||||
* Charge utile : csrf_token.
|
||||
*/
|
||||
public function genererJetonsManquants(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
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) pour les identités sans compte.', count($produits)),
|
||||
'contexte' => ['nombre' => count($produits)],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => $produits === []
|
||||
? 'Toutes les identités actives 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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/dsi_journal.php — recherche paginée dans le journal.
|
||||
*
|
||||
* Paramètres GET : categorie, resultat, role, id_compte, q, du, au, page,
|
||||
* taille.
|
||||
*/
|
||||
public function journal(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
|
||||
$resultat = $this->journal->rechercher([
|
||||
'categorie' => (string) ($_GET['categorie'] ?? ''),
|
||||
'resultat' => (string) ($_GET['resultat'] ?? ''),
|
||||
'role' => (string) ($_GET['role'] ?? ''),
|
||||
'id_compte' => (int) ($_GET['id_compte'] ?? 0),
|
||||
'q' => (string) ($_GET['q'] ?? ''),
|
||||
'du' => (string) ($_GET['du'] ?? ''),
|
||||
'au' => (string) ($_GET['au'] ?? ''),
|
||||
'page' => (int) ($_GET['page'] ?? 1),
|
||||
'taille' => (int) ($_GET['taille'] ?? JournalModel::TAILLE_PAGE),
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, array_merge($resultat, [
|
||||
'statistiques' => $this->journal->statistiques(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_journal_purge.php — supprime les entrées les plus anciennes.
|
||||
*
|
||||
* La purge est elle-même journalisée, et l'est APRÈS coup : sa propre trace
|
||||
* ne peut donc pas être emportée par la purge qu'elle décrit.
|
||||
*
|
||||
* Charge utile : jours (minimum 30), csrf_token.
|
||||
*/
|
||||
public function purgerJournal(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$jours = (int) (filter_var($payload['jours'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
if ($jours < 30) {
|
||||
JsonResponse::send(422, ['error' => 'La purge ne peut pas descendre sous 30 jours de conservation.']);
|
||||
}
|
||||
|
||||
$supprimees = $this->journal->purger($jours);
|
||||
|
||||
Journal::administration('JOURNAL_PURGE', [
|
||||
'cible_table' => 'journal_activite',
|
||||
'cible_id' => null,
|
||||
'description' => sprintf('Purge du journal : %d entrée(s) de plus de %d jours supprimée(s).', $supprimees, $jours),
|
||||
'contexte' => ['jours' => $jours, 'supprimees' => $supprimees],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf('%d entrée(s) supprimée(s).', $supprimees),
|
||||
'supprimees' => $supprimees,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rôles proposés à la création d'un compte, avec leur libellé.
|
||||
*
|
||||
* @return array<int, array{code: string, libelle: string, identite: string}>
|
||||
*/
|
||||
private function rolesDisponibles(): array
|
||||
{
|
||||
return [
|
||||
['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'],
|
||||
['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'],
|
||||
['code' => Access::ROLE_SERVICE, 'libelle' => 'Service Enfance (administrateur)', 'identite' => 'LIBELLE'],
|
||||
['code' => Access::ROLE_DSI, 'libelle' => 'DSI', 'identite' => 'LIBELLE'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -254,12 +254,83 @@ final class PageController extends Controller
|
||||
{
|
||||
$this->access->requirePage('administration');
|
||||
$this->renderPage('administration', 'Administration', 'administration', [
|
||||
'pageSubtitle' => 'Les référentiels de PTA : agents, lieux d’affectation, postes et horaires de couverture attendus.',
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
'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.
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'posts' => (new AgentModel($this->pdo))->allPosts(),
|
||||
'scripts' => ['administration.js', 'coverage.js'],
|
||||
'modules' => ['administration', 'coverage'],
|
||||
'scripts' => ['coverage.js'],
|
||||
'modules' => ['coverage'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Management — Agents (V7.14) : création, liste et fiche des agents.
|
||||
*
|
||||
* Ce référentiel partageait l'écran « Administration » avec celui des lieux
|
||||
* et les horaires de couverture, trois objets sans rapport. Chacun a
|
||||
* désormais son onglet, réunis sous le groupe « Management » de la barre de
|
||||
* navigation.
|
||||
*
|
||||
* NOM DE MÉTHODE EN SNAKE_CASE, à rebours du reste du projet : le nom de la
|
||||
* méthode EST la clé de page passée à runPage() et inscrite dans
|
||||
* acces_role_page, où les clés sont en minuscules. Les garder identiques
|
||||
* prime sur l'uniformité de style — voir l'en-tête de cette classe.
|
||||
*
|
||||
* La liste des lieux est nécessaire ici aussi : le formulaire de création
|
||||
* demande le lieu principal de l'agent.
|
||||
*/
|
||||
public function manage_agents(): void
|
||||
{
|
||||
$this->access->requirePage('manage_agents');
|
||||
$agentModel = new AgentModel($this->pdo);
|
||||
$this->renderPage('manage_agents', 'Agents', 'manage_agents', [
|
||||
'pageSubtitle' => 'Créer un agent, corriger sa fiche, changer son lieu principal de rattachement.',
|
||||
'agents' => $agentModel->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'posts' => $agentModel->allPosts(),
|
||||
'scripts' => ['administration.js'],
|
||||
'modules' => ['administration'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Management — Lieux d'affectation (V7.14) : création et paramètres.
|
||||
*
|
||||
* Les horaires de couverture d'un lieu restent sur « Administration » : ils
|
||||
* ne décrivent pas le lieu mais une exigence de présence.
|
||||
*/
|
||||
public function manage_structures(): void
|
||||
{
|
||||
$this->access->requirePage('manage_structures');
|
||||
$this->renderPage('manage_structures', 'Lieux d’affectation', 'manage_structures', [
|
||||
'pageSubtitle' => 'Créer un lieu d’affectation et définir son type : périscolaire ou extrascolaire.',
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'scripts' => ['administration.js'],
|
||||
'modules' => ['administration'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
|
||||
*
|
||||
* Page d'administration TECHNIQUE, ouverte au seul rôle DSI par
|
||||
* acces_role_page. Elle ne charge aucun référentiel métier — la DSI n'a rien
|
||||
* à connaître des agents ni des plannings ; les seules listes qu'elle
|
||||
* manipule (identités sans compte) sont servies par l'API.
|
||||
*/
|
||||
public function dsi(): void
|
||||
{
|
||||
$this->access->requirePage('dsi');
|
||||
$this->renderPage('dsi', 'Espace DSI', 'dsi', [
|
||||
'pageSubtitle' => 'Les comptes d’accès de l’application, leurs jetons, et le journal des connexions, des écritures et des refus.',
|
||||
'scripts' => ['dsi.js'],
|
||||
'modules' => ['dsi'],
|
||||
'pageConfig' => [
|
||||
// Lien profond « journal de ce compte » : filtre d'emblée le
|
||||
// journal sur le compte désigné.
|
||||
'compteId' => $this->positiveInt($_GET['compte_id'] ?? null),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\MotifCategories;
|
||||
use App\Core\Request;
|
||||
@@ -164,6 +165,7 @@ final class PlanningController
|
||||
// affectable à un agent sans droit à l'enveloppe de formation.
|
||||
$this->assertMotifsAllowedForAgent($rawEntries, $agent);
|
||||
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent));
|
||||
$this->assertDatesWithinContract($agent, array_column($entries, 'date'));
|
||||
$this->assertNoInternalOverlap($entries);
|
||||
$this->assertNoOtherLocationOverlap(
|
||||
$entries,
|
||||
@@ -193,6 +195,14 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'PLANNING_ENREGISTRE',
|
||||
'planning',
|
||||
$planningId,
|
||||
sprintf('Brouillon enregistré : %d créneau(x) pour l’agent #%d, semaine %s.', count($entries), (int) $agentId, $weekValue),
|
||||
['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'semaine' => $weekValue]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'planning_id' => $planningId,
|
||||
@@ -264,6 +274,7 @@ final class PlanningController
|
||||
if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
|
||||
JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']);
|
||||
}
|
||||
$this->assertDatesWithinContract($entryAgent, [$date]);
|
||||
|
||||
try {
|
||||
$result = $this->planningModel->updateEntryFromAgentView(
|
||||
@@ -284,6 +295,14 @@ final class PlanningController
|
||||
}
|
||||
|
||||
$reopened = count($result['reopened_planning_ids']) > 0;
|
||||
Journal::ecriture(
|
||||
'CRENEAU_MODIFIE',
|
||||
'creneau_horaire',
|
||||
(int) $entryId,
|
||||
sprintf('Créneau du %s modifié (%s-%s) pour l’agent #%d.', $date, $start, $end, (int) $result['agent_id']),
|
||||
['id_agent' => (int) $result['agent_id'], 'id_structure' => (int) $structureId, 'revalidation' => $reopened]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $reopened
|
||||
@@ -337,6 +356,14 @@ final class PlanningController
|
||||
$message .= ' Le planning validé concerné a été repassé en brouillon.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'CRENEAU_SUPPRIME',
|
||||
'creneau_horaire',
|
||||
(int) $entryId,
|
||||
sprintf('Créneau du %s supprimé pour l’agent #%d.', (string) $result['date'], (int) $result['agent_id']),
|
||||
['id_agent' => (int) $result['agent_id'], 'revalidation' => (bool) $result['reopened']]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -383,6 +410,12 @@ final class PlanningController
|
||||
if (!$validDate || (int) $dateObj->format('N') > 5) {
|
||||
JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']);
|
||||
}
|
||||
|
||||
$entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
|
||||
if ($entryAgent === null) {
|
||||
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
|
||||
}
|
||||
$this->assertDatesWithinContract($entryAgent, [$targetDate]);
|
||||
try {
|
||||
$result = $this->planningModel->moveOrDuplicateEntry(
|
||||
(int) $entryId,
|
||||
@@ -410,6 +443,14 @@ final class PlanningController
|
||||
$message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
$result['action'] === 'duplicate' ? 'CRENEAU_DUPLIQUE' : 'CRENEAU_DEPLACE',
|
||||
'creneau_horaire',
|
||||
(int) $entryId,
|
||||
sprintf('Créneau reporté au %s pour l’agent #%d.', $targetDate, (int) $result['agent_id']),
|
||||
['id_agent' => (int) $result['agent_id'], 'action' => $result['action'], 'revalidation' => $reopened]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'action' => $result['action'],
|
||||
@@ -448,9 +489,15 @@ final class PlanningController
|
||||
if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) {
|
||||
JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']);
|
||||
}
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
// La copie ne connaît pas d'avance les jours réellement recopiés : on
|
||||
// contrôle les cinq jours ouvrés de la semaine cible, ce qui refuse une
|
||||
// semaine à cheval sur la fin d'un contrat aussi bien qu'une semaine
|
||||
// entièrement hors contrat.
|
||||
$this->assertDatesWithinContract($agent, $this->workingDaysOfWeek($targetWeek[0], $targetWeek[1]));
|
||||
|
||||
try {
|
||||
$result = $this->planningModel->copyAgentWeek(
|
||||
@@ -479,6 +526,25 @@ final class PlanningController
|
||||
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_COPIEE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf(
|
||||
'%d créneau(x) copiés de la semaine %s vers %s pour l’agent #%d.',
|
||||
(int) $result['copied_entries'],
|
||||
$sourceWeekValue,
|
||||
$targetWeekValue,
|
||||
(int) $agentId
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'semaine_source' => $sourceWeekValue,
|
||||
'semaine_cible' => $targetWeekValue,
|
||||
'mode' => $mode,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -531,6 +597,10 @@ final class PlanningController
|
||||
* - AUCUN_CRENEAU : planning vide ;
|
||||
* - CRENEAU_HORS_SEMAINE : créneau hors des bornes de la semaine ;
|
||||
* - CHEVAUCHEMENT_INTERNE : deux créneaux du planning se recouvrent ;
|
||||
* - HORS_CONTRAT : affectation antérieure à l'arrivée de l'agent ou
|
||||
* postérieure à son départ (V7.15). Les gardes d'écriture empêchent d'en
|
||||
* créer, ce contrôle attrape celles qui préexistaient ou qu'un contrat
|
||||
* raccourci a rendues illégitimes ;
|
||||
* - CHEVAUCHEMENT_AUTRE_STRUCTURE : recouvrement avec un créneau DÉJÀ
|
||||
* VALIDÉ sur un autre lieu. Seuls les plannings validés sont opposables :
|
||||
* un brouillon concurrent peut encore être corrigé, le bloquer
|
||||
@@ -569,6 +639,19 @@ final class PlanningController
|
||||
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
|
||||
}
|
||||
|
||||
foreach ($this->planningModel->entriesOutsideContract((int) $planningId) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
'ERREUR',
|
||||
'HORS_CONTRAT',
|
||||
sprintf(
|
||||
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
|
||||
$row['id_creneau'],
|
||||
$row['date_jour']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
@@ -643,6 +726,25 @@ final class PlanningController
|
||||
$this->planningModel->markValidated((int) $planningId);
|
||||
$this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.');
|
||||
|
||||
// Journalisé APRÈS le passage en base : une validation refusée par les
|
||||
// contrôles métier plus haut n'a pas eu lieu, elle n'a rien à tracer.
|
||||
Journal::ecriture(
|
||||
'PLANNING_VALIDE',
|
||||
'planning',
|
||||
(int) $planningId,
|
||||
sprintf(
|
||||
'Planning validé pour l’agent #%d, semaine du %s.',
|
||||
(int) $planning['id_agent'],
|
||||
(string) $planning['date_debut']
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $planning['id_agent'],
|
||||
'id_structure' => isset($planning['id_structure']) ? (int) $planning['id_structure'] : null,
|
||||
'date_debut' => (string) $planning['date_debut'],
|
||||
'quota_force' => $forceQuota,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'status' => 'validated',
|
||||
@@ -687,6 +789,18 @@ final class PlanningController
|
||||
JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'PLANNING_ROUVERT',
|
||||
'planning',
|
||||
(int) $planningId,
|
||||
sprintf(
|
||||
'Planning repassé en brouillon pour l’agent #%d, semaine du %s.',
|
||||
(int) $planning['id_agent'],
|
||||
(string) $planning['date_debut']
|
||||
),
|
||||
['id_agent' => (int) $planning['id_agent'], 'date_debut' => (string) $planning['date_debut']]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'status' => 'BROUILLON',
|
||||
@@ -892,6 +1006,52 @@ final class PlanningController
|
||||
* @param array<int, array{niveau: string, code: string, message: string}> $controls
|
||||
* @param string $level ERREUR (bloquant), ALERTE (confirmable) ou INFO.
|
||||
*/
|
||||
/**
|
||||
* Refuse toute affectation hors des dates de contrat de l'agent (V7.15).
|
||||
*
|
||||
* Un agent n'existe pour PTA qu'entre sa date de début et sa date de fin de
|
||||
* contrat : le planifier avant son arrivée ou après son départ produit des
|
||||
* heures que personne ne fera, et fausse les cumuls annuels comme les
|
||||
* contrôles de couverture. La règle s'applique à TOUS les motifs, congés et
|
||||
* absences compris — hors contrat, il n'y a rien à décompter.
|
||||
*
|
||||
* Toutes les dates sont contrôlées d'un coup pour que l'utilisateur voie
|
||||
* l'ensemble des jours refusés, et non le premier.
|
||||
*
|
||||
* @param array<string, mixed> $agent Fiche agent.
|
||||
* @param array<int, string> $dates Dates AAAA-MM-JJ visées par l'écriture.
|
||||
*/
|
||||
private function assertDatesWithinContract(array $agent, array $dates): void
|
||||
{
|
||||
$hors = AgentModel::datesOutsideContract($agent, $dates);
|
||||
if ($hors === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonResponse::send(422, [
|
||||
'error' => AgentModel::contractViolationMessage($agent, $hors),
|
||||
'code' => 'HORS_CONTRAT',
|
||||
'dates' => $hors,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les cinq jours ouvrés d'une semaine ISO, au format AAAA-MM-JJ.
|
||||
* Le modèle métier s'arrête au vendredi : samedi et dimanche n'ont pas à
|
||||
* être contrôlés, aucun créneau ne peut y être posé.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function workingDaysOfWeek(int $year, int $weekNumber): array
|
||||
{
|
||||
$lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
|
||||
|
||||
return array_map(
|
||||
static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'),
|
||||
range(0, 4)
|
||||
);
|
||||
}
|
||||
|
||||
private function addControl(array &$controls, string $level, string $code, string $message): void
|
||||
{
|
||||
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||||
|
||||
@@ -6,6 +6,7 @@ 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\AgentModel;
|
||||
@@ -111,6 +112,15 @@ final class PtaController
|
||||
JsonResponse::send(422, ['error' => 'Contrainte invalide.']);
|
||||
}
|
||||
$deleted = $this->ptaModel->deleteConstraint((int) $constraintId, (int) $agentId);
|
||||
if ($deleted) {
|
||||
Journal::ecriture(
|
||||
'CONTRAINTE_SUPPRIMEE',
|
||||
'agent_contrainte',
|
||||
(int) $constraintId,
|
||||
sprintf('Contrainte prioritaire #%d supprimée pour l’agent #%d.', (int) $constraintId, (int) $agentId),
|
||||
['id_agent' => (int) $agentId]
|
||||
);
|
||||
}
|
||||
JsonResponse::send($deleted ? 200 : 404, [
|
||||
'message' => $deleted ? 'La contrainte a été supprimée.' : 'Contrainte introuvable.',
|
||||
]);
|
||||
@@ -155,6 +165,13 @@ final class PtaController
|
||||
'interdit_soir' => !empty($payload['interdit_soir']) ? 1 : 0,
|
||||
'commentaire' => $comment,
|
||||
]);
|
||||
Journal::ecriture(
|
||||
'CONTRAINTE_ENREGISTREE',
|
||||
'agent_contrainte',
|
||||
(int) $id,
|
||||
sprintf('Contrainte « %s » enregistrée pour l’agent #%d à partir du %s.', $type, (int) $agentId, $start),
|
||||
['id_agent' => (int) $agentId, 'type_contrainte' => $type, 'date_debut' => $start, 'date_fin' => $end]
|
||||
);
|
||||
JsonResponse::send(201, ['message' => 'La contrainte prioritaire a été enregistrée.', 'id_contrainte' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
// INCOHÉRENCE CONNUE : le détail de l'exception est renvoyé au
|
||||
@@ -196,6 +213,15 @@ final class PtaController
|
||||
JsonResponse::send(422, ['error' => 'Préférence invalide.']);
|
||||
}
|
||||
$deleted = $this->ptaModel->deleteWish((int) $wishId, (int) $agentId);
|
||||
if ($deleted) {
|
||||
Journal::ecriture(
|
||||
'SOUHAIT_SUPPRIME',
|
||||
'agent_structure_souhait',
|
||||
(int) $wishId,
|
||||
sprintf('Préférence de lieu #%d supprimée pour l’agent #%d.', (int) $wishId, (int) $agentId),
|
||||
['id_agent' => (int) $agentId]
|
||||
);
|
||||
}
|
||||
JsonResponse::send($deleted ? 200 : 404, [
|
||||
'message' => $deleted ? 'La préférence ou interdiction a été supprimée.' : 'Élément introuvable.',
|
||||
]);
|
||||
@@ -224,6 +250,13 @@ final class PtaController
|
||||
'distance_km' => $distance,
|
||||
'commentaire' => $comment !== '' ? $comment : null,
|
||||
]);
|
||||
Journal::ecriture(
|
||||
'SOUHAIT_ENREGISTRE',
|
||||
'agent_structure_souhait',
|
||||
(int) $id,
|
||||
sprintf('Préférence « %s » enregistrée pour l’agent #%d sur le lieu #%d.', $type, (int) $agentId, (int) $structureId),
|
||||
['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'type_souhait' => $type]
|
||||
);
|
||||
JsonResponse::send(201, ['message' => 'La préférence de lieu a été enregistrée.', 'id_souhait' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la préférence.', 'details' => $e->getMessage()]);
|
||||
|
||||
@@ -7,90 +7,438 @@ namespace App\Controllers;
|
||||
use App\Core\Access;
|
||||
use App\Core\Controller;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\StructureModel;
|
||||
use App\Core\Session;
|
||||
use App\Models\CompteModel;
|
||||
use App\Models\JournalModel;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Sélection du rôle et de l'identité de travail.
|
||||
* Connexion — point d'entrée de l'application (role.php).
|
||||
*
|
||||
* ATTENTION : ce n'est PAS une authentification. L'utilisateur choisit
|
||||
* librement qui il est, sans mot de passe ni vérification. C'est la faille
|
||||
* ouverte la plus importante du projet, assumée pour cette version. Un passage
|
||||
* en production doit remplacer cet écran par une authentification réelle — le
|
||||
* reste de la mécanique de droits (Access, vues SQL) n'a pas à bouger, il
|
||||
* suffit que la session soit renseignée par un compte vérifié.
|
||||
* JUSQU'À LA V7.10, CET ÉCRAN ÉTAIT UN SÉLECTEUR LIBRE : chacun choisissait son
|
||||
* rôle et son identité dans une liste, sans rien prouver. Depuis la V7.11, une
|
||||
* session ne peut naître que de la présentation d'un JETON valide, distribué par
|
||||
* la DSI. Le reste de la mécanique de droits n'a pas bougé : Access lit toujours
|
||||
* les mêmes vues SQL, simplement le rôle n'est plus déclaré par l'utilisateur.
|
||||
*
|
||||
* Contrairement aux autres pages, ce contrôleur n'est pas appelé par runPage() :
|
||||
* il s'exécute AVANT tout contrôle d'accès, puisqu'il est ce qui permet d'en
|
||||
* avoir un.
|
||||
* La V7.12 ajoute un SECOND FACTEUR pour les comptes qui engagent l'application
|
||||
* — Service Enfance (qui valide les plannings de toute la collectivité) et DSI
|
||||
* (qui distribue les accès) : après le jeton, un mot de passe. Les deux secrets
|
||||
* sont de nature différente, et c'est tout l'intérêt : le jeton est remis par la
|
||||
* DSI et peut circuler par courriel, le mot de passe est choisi par le titulaire
|
||||
* à sa première connexion et n'est connu que de lui. La DSI ne peut que
|
||||
* l'effacer, jamais le lire ni le remplacer.
|
||||
*
|
||||
* La connexion se fait donc en deux étapes, matérialisées par $etape :
|
||||
* « jeton » — saisie du jeton (ou arrivée par role.php?token=…) ;
|
||||
* « definition » — premier accès d'un compte privilégié : choisir son mot de passe ;
|
||||
* « verification » — accès suivants : le saisir ;
|
||||
* « changement » — session ouverte, le titulaire remplace son mot de passe.
|
||||
*
|
||||
* Entre les deux étapes, RIEN N'EST OUVERT : la session ne porte qu'une attente
|
||||
* horodatée (ATTENTE_KEY), qui périme au bout de cinq minutes. Un jeton validé
|
||||
* sans mot de passe ne donne accès à aucune page.
|
||||
*
|
||||
* Comme avant, ce contrôleur n'est pas appelé par runPage() : il s'exécute AVANT
|
||||
* tout contrôle d'accès, puisqu'il est ce qui permet d'en avoir un.
|
||||
*/
|
||||
final class RoleController extends Controller
|
||||
{
|
||||
/** Fenêtre et plafond du frein anti-force-brute, par adresse IP. */
|
||||
private const FENETRE_MINUTES = 15;
|
||||
private const ECHECS_MAX = 10;
|
||||
|
||||
/** Attente entre les deux étapes : le jeton est reconnu, le mot de passe manque. */
|
||||
private const ATTENTE_KEY = 'pta_auth_attente';
|
||||
private const ATTENTE_SECONDES = 300;
|
||||
|
||||
private CompteModel $comptes;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->comptes = new CompteModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche le sélecteur et traite sa soumission.
|
||||
*
|
||||
* Trois chemins dans une seule méthode, faute de routeur :
|
||||
* - GET ?action=reset : abandon du rôle courant, puis redirection ;
|
||||
* - POST : tentative de sélection, puis redirection vers
|
||||
* l'accueil du rôle ou réaffichage avec $error ;
|
||||
* - GET simple : affichage du formulaire.
|
||||
* Affiche l'écran de connexion et traite ses chemins, faute de routeur :
|
||||
* - GET ?action=reset : déconnexion, puis retour à cet écran ;
|
||||
* - GET ?action=mot_de_passe : changement de mot de passe, session ouverte ;
|
||||
* - GET ?token=… : première étape par lien direct ;
|
||||
* - POST etape=mot_de_passe : seconde étape (définition ou vérification) ;
|
||||
* - POST : première étape par le champ de saisie.
|
||||
*
|
||||
* Le CSRF est vérifié à la main plutôt que par Csrf::assertPayload() : les
|
||||
* données arrivent ici en formulaire classique ($_POST / $_GET) et non en
|
||||
* JSON, et un échec doit produire une page HTML, pas une réponse JSON.
|
||||
* Un jeton invalide sur le reset est ignoré EN SILENCE : l'utilisateur est
|
||||
* simplement renvoyé au sélecteur, sans message d'échec inutile.
|
||||
* données arrivent en formulaire classique et un échec doit produire une page
|
||||
* HTML, pas une réponse JSON.
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$access = new Access($this->pdo);
|
||||
|
||||
// Le changement de rôle modifie l'état de la session : il est protégé
|
||||
// par le jeton CSRF au même titre que les écritures de l'API.
|
||||
if (($_GET['action'] ?? '') === 'reset') {
|
||||
$provided = (string) ($_GET['csrf_token'] ?? '');
|
||||
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
|
||||
$access->clear();
|
||||
}
|
||||
header('Location: role.php', true, 302);
|
||||
exit;
|
||||
$this->deconnecter($access);
|
||||
}
|
||||
|
||||
$error = '';
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
$expected = Csrf::ensureToken();
|
||||
$provided = (string) ($_POST['csrf_token'] ?? '');
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
$notice = '';
|
||||
$etape = 'jeton';
|
||||
$methode = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
|
||||
// Changement volontaire du mot de passe : le seul chemin qui exige une
|
||||
// session DÉJÀ ouverte.
|
||||
if (($_GET['action'] ?? '') === 'mot_de_passe' || ($_POST['etape'] ?? '') === 'changement') {
|
||||
[$etape, $error, $notice] = $this->changerMotDePasse($access, $methode);
|
||||
} elseif ($methode === 'POST') {
|
||||
if (!$this->csrfValide()) {
|
||||
$error = 'La session a expiré. Rechargez la page et recommencez.';
|
||||
$etape = $this->attente() !== null ? $this->etapeAttendue() : 'jeton';
|
||||
} elseif (($_POST['etape'] ?? '') === 'mot_de_passe') {
|
||||
[$etape, $error] = $this->traiterMotDePasse($access);
|
||||
} else {
|
||||
try {
|
||||
$role = strtoupper(trim((string) ($_POST['role'] ?? '')));
|
||||
$agentId = filter_var($_POST['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$structureId = filter_var($_POST['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$access->select($role, $agentId ? (int) $agentId : null, $structureId ? (int) $structureId : null);
|
||||
header('Location: ' . $access->homeUrl(), true, 302);
|
||||
exit;
|
||||
} catch (\DomainException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
[$etape, $error] = $this->connecter($access, (string) ($_POST['token'] ?? ''), 'FORMULAIRE');
|
||||
}
|
||||
} elseif (isset($_GET['token'])) {
|
||||
[$etape, $error] = $this->connecter($access, (string) $_GET['token'], 'LIEN');
|
||||
} elseif ($this->attente() !== null) {
|
||||
// Rechargement de la page au milieu de la connexion : on reste sur la
|
||||
// seconde étape plutôt que de renvoyer l'utilisateur à son jeton.
|
||||
$etape = $this->etapeAttendue();
|
||||
}
|
||||
|
||||
$attente = $this->attente();
|
||||
|
||||
Security::sendHtmlHeaders();
|
||||
$this->render('pages/role', [
|
||||
'pageTitle' => 'Choisir votre accès',
|
||||
'pageTitle' => 'Connexion',
|
||||
'cspNonce' => Security::nonce(),
|
||||
'csrfToken' => Csrf::ensureToken(),
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'error' => $error,
|
||||
'notice' => $notice,
|
||||
'etape' => $etape,
|
||||
'titulaire' => (string) ($attente['libelle'] ?? ''),
|
||||
'longueurMinimale' => CompteModel::MOT_DE_PASSE_MINIMUM,
|
||||
'currentProfile' => $access->isConfigured() ? $access->profile() : null,
|
||||
'homeUrl' => $access->isConfigured() ? $access->homeUrl() : 'role.php',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Première étape : vérifie le jeton.
|
||||
*
|
||||
* Ne revient QUE si la connexion n'est pas allée à son terme — une connexion
|
||||
* complète redirige et termine le script.
|
||||
*
|
||||
* @param string $origine « FORMULAIRE » ou « LIEN », conservé au journal :
|
||||
* c'est ce qui permet à la DSI de voir si les liens
|
||||
* directs circulent.
|
||||
*
|
||||
* @return array{0: string, 1: string} [étape à afficher, message d'erreur]
|
||||
*/
|
||||
private function connecter(Access $access, string $jetonBrut, string $origine): array
|
||||
{
|
||||
$jeton = CompteModel::normaliser($jetonBrut);
|
||||
if ($jeton === '') {
|
||||
return ['jeton', 'Saisissez votre jeton d’accès.'];
|
||||
}
|
||||
|
||||
$blocage = $this->blocageEnCours();
|
||||
if ($blocage !== '') {
|
||||
return ['jeton', $blocage];
|
||||
}
|
||||
|
||||
$compte = $this->comptes->authentifier($jeton);
|
||||
if ($compte === null) {
|
||||
// Ni le jeton présenté ni aucun fragment ne sont journalisés : une
|
||||
// trace du secret dans le journal en ferait un secret de moins.
|
||||
Journal::connexion('JETON_REFUSE', [
|
||||
'resultat' => 'REFUS',
|
||||
'description' => 'Jeton d’accès inconnu, révoqué ou porté par une identité inactive.',
|
||||
'contexte' => ['origine' => $origine],
|
||||
]);
|
||||
|
||||
return ['jeton', 'Ce jeton d’accès est inconnu ou n’est plus valide. Rapprochez-vous de la DSI.'];
|
||||
}
|
||||
|
||||
// Agents et responsables : le jeton suffit, la session s'ouvre ici.
|
||||
if (!CompteModel::exigeMotDePasse($compte)) {
|
||||
$this->ouvrirSession($access, $compte, ['jeton'], $origine);
|
||||
}
|
||||
|
||||
// Comptes privilégiés : on retient l'attente et on demande le second
|
||||
// facteur. Rien n'est ouvert tant qu'il n'est pas fourni.
|
||||
$this->poserAttente($compte, $origine);
|
||||
|
||||
Journal::connexion('JETON_ACCEPTE_ATTENTE_MDP', [
|
||||
'id_compte' => (int) $compte['id_compte'],
|
||||
'role' => (string) $compte['role'],
|
||||
'acteur' => (string) $compte['identite_libelle'],
|
||||
'description' => 'Jeton accepté ; mot de passe attendu pour ouvrir la session.',
|
||||
'contexte' => ['origine' => $origine],
|
||||
]);
|
||||
|
||||
return [$this->etapeAttendue(), ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconde étape : définition ou vérification du mot de passe.
|
||||
*
|
||||
* Le compte est RELU EN BASE plutôt que repris de la session : entre les deux
|
||||
* étapes, la DSI a pu le désactiver, ou l'agent qui le porte être fermé.
|
||||
*
|
||||
* @return array{0: string, 1: string} [étape à afficher, message d'erreur]
|
||||
*/
|
||||
private function traiterMotDePasse(Access $access): array
|
||||
{
|
||||
$attente = $this->attente();
|
||||
if ($attente === null) {
|
||||
return ['jeton', 'La connexion a expiré. Saisissez de nouveau votre jeton d’accès.'];
|
||||
}
|
||||
|
||||
$compte = $this->comptes->trouverPourAuthentification((int) $attente['compte_id']);
|
||||
if ($compte === null || !CompteModel::exigeMotDePasse($compte)) {
|
||||
$this->oublierAttente();
|
||||
|
||||
return ['jeton', 'Ce compte n’est plus accessible. Rapprochez-vous de la DSI.'];
|
||||
}
|
||||
|
||||
$motDePasse = (string) ($_POST['mot_de_passe'] ?? '');
|
||||
|
||||
// Premier accès : le titulaire choisit son mot de passe. Personne ne le
|
||||
// lui a transmis, et personne ne pourra le lire ensuite.
|
||||
if (!CompteModel::motDePasseDefini($compte)) {
|
||||
try {
|
||||
$this->comptes->definirMotDePasse(
|
||||
(int) $compte['id_compte'],
|
||||
$motDePasse,
|
||||
(string) ($_POST['mot_de_passe_confirmation'] ?? '')
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
return ['definition', $e->getMessage()];
|
||||
}
|
||||
|
||||
$this->ouvrirSession($access, $compte, ['jeton', 'mot_de_passe'], (string) $attente['origine'], true);
|
||||
}
|
||||
|
||||
$blocage = $this->blocageEnCours();
|
||||
if ($blocage !== '') {
|
||||
return ['verification', $blocage];
|
||||
}
|
||||
|
||||
if (!$this->comptes->verifierMotDePasse($compte, $motDePasse)) {
|
||||
Journal::connexion('MOT_DE_PASSE_REFUSE', [
|
||||
'resultat' => 'REFUS',
|
||||
'id_compte' => (int) $compte['id_compte'],
|
||||
'role' => (string) $compte['role'],
|
||||
'acteur' => (string) $compte['identite_libelle'],
|
||||
'description' => 'Mot de passe incorrect après un jeton valide.',
|
||||
]);
|
||||
|
||||
return ['verification', 'Mot de passe incorrect.'];
|
||||
}
|
||||
|
||||
$this->ouvrirSession($access, $compte, ['jeton', 'mot_de_passe'], (string) $attente['origine']);
|
||||
|
||||
return ['jeton', '']; // Inatteignable : ouvrirSession() redirige.
|
||||
}
|
||||
|
||||
/**
|
||||
* Changement de mot de passe par son titulaire, session ouverte.
|
||||
*
|
||||
* L'ancien mot de passe est exigé : sans lui, un poste laissé déverrouillé
|
||||
* suffirait à confisquer un compte.
|
||||
*
|
||||
* @return array{0: string, 1: string, 2: string} [étape, erreur, message de succès]
|
||||
*/
|
||||
private function changerMotDePasse(Access $access, string $methode): array
|
||||
{
|
||||
if (!$access->isConfigured()) {
|
||||
return ['jeton', 'Connectez-vous avant de changer votre mot de passe.', ''];
|
||||
}
|
||||
|
||||
$compte = $this->comptes->trouverPourAuthentification((int) $access->accountId());
|
||||
if ($compte === null || !CompteModel::exigeMotDePasse($compte)) {
|
||||
return ['jeton', 'Votre type de compte n’utilise pas de mot de passe.', ''];
|
||||
}
|
||||
|
||||
if ($methode !== 'POST') {
|
||||
return ['changement', '', ''];
|
||||
}
|
||||
if (!$this->csrfValide()) {
|
||||
return ['changement', 'La session a expiré. Rechargez la page et recommencez.', ''];
|
||||
}
|
||||
|
||||
if (!$this->comptes->verifierMotDePasse($compte, (string) ($_POST['mot_de_passe_actuel'] ?? ''))) {
|
||||
Journal::refus('MOT_DE_PASSE_REFUSE', 'Mot de passe actuel incorrect lors d’un changement.');
|
||||
|
||||
return ['changement', 'Le mot de passe actuel est incorrect.', ''];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->comptes->definirMotDePasse(
|
||||
(int) $compte['id_compte'],
|
||||
(string) ($_POST['mot_de_passe'] ?? ''),
|
||||
(string) ($_POST['mot_de_passe_confirmation'] ?? '')
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
return ['changement', $e->getMessage(), ''];
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'MOT_DE_PASSE_MODIFIE',
|
||||
'compte_acces',
|
||||
(int) $compte['id_compte'],
|
||||
'Mot de passe changé par son titulaire.'
|
||||
);
|
||||
|
||||
return ['changement', '', 'Votre mot de passe a été modifié.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre la session et redirige : cette méthode NE REVIENT JAMAIS.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte.
|
||||
* @param array<int, string> $facteurs Facteurs réellement présentés.
|
||||
* @param bool $premier Vrai si le mot de passe vient d'être défini.
|
||||
*/
|
||||
private function ouvrirSession(Access $access, array $compte, array $facteurs, string $origine, bool $premier = false): never
|
||||
{
|
||||
$this->oublierAttente();
|
||||
$access->openSession($compte);
|
||||
$this->comptes->enregistrerConnexion((int) $compte['id_compte']);
|
||||
|
||||
if ($premier) {
|
||||
// Journalisé APRÈS l'ouverture de session, pour que la trace porte le
|
||||
// nom du titulaire plutôt que d'être anonyme.
|
||||
Journal::ecriture(
|
||||
'MOT_DE_PASSE_DEFINI',
|
||||
'compte_acces',
|
||||
(int) $compte['id_compte'],
|
||||
'Mot de passe défini par son titulaire à sa première connexion.'
|
||||
);
|
||||
}
|
||||
|
||||
Journal::connexion('CONNEXION_REUSSIE', [
|
||||
'description' => sprintf(
|
||||
'%s s’est connecté (%s).',
|
||||
(string) ($compte['identite_libelle'] ?? $compte['libelle']),
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => ['origine' => $origine, 'facteurs' => $facteurs],
|
||||
]);
|
||||
|
||||
// La redirection efface le jeton de la barre d'adresse quand il est
|
||||
// arrivé par un lien direct.
|
||||
header('Location: ' . $access->homeUrl(), true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Déconnexion : trace d'abord, vide la session ensuite, redirige enfin. */
|
||||
private function deconnecter(Access $access): never
|
||||
{
|
||||
$provided = (string) ($_GET['csrf_token'] ?? '');
|
||||
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
|
||||
if ($access->isConfigured()) {
|
||||
// L'ordre compte : après clear(), la session ne dit plus qui
|
||||
// vient de partir et la trace serait anonyme.
|
||||
Journal::connexion('DECONNEXION', ['description' => 'Fin de session à la demande de l’utilisateur.']);
|
||||
}
|
||||
$this->oublierAttente();
|
||||
$access->clear();
|
||||
}
|
||||
|
||||
header('Location: role.php', true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frein anti-force-brute, commun aux deux étapes : au-delà du plafond, plus
|
||||
* aucun secret n'est même comparé.
|
||||
*
|
||||
* @return string Message à afficher, vide si la voie est libre.
|
||||
*/
|
||||
private function blocageEnCours(): string
|
||||
{
|
||||
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$echecs = (new JournalModel($this->pdo))->echecsRecents($ip, self::FENETRE_MINUTES);
|
||||
if ($echecs < self::ECHECS_MAX) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Le blocage est tracé pour que la DSI voie une attaque en cours plutôt
|
||||
// que d'avoir à la déduire.
|
||||
Journal::connexion('CONNEXION_BLOQUEE', [
|
||||
'resultat' => 'REFUS',
|
||||
'description' => sprintf(
|
||||
'Trop de secrets refusés depuis %s : connexion bloquée.',
|
||||
$ip !== '' ? $ip : 'une adresse inconnue'
|
||||
),
|
||||
'contexte' => ['echecs' => $echecs, 'fenetre_minutes' => self::FENETRE_MINUTES],
|
||||
]);
|
||||
|
||||
return sprintf(
|
||||
'Trop de tentatives infructueuses. Réessayez dans %d minutes ou contactez la DSI.',
|
||||
self::FENETRE_MINUTES
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mémorise le compte reconnu par son jeton, en attendant son mot de passe.
|
||||
*
|
||||
* @param array<string, mixed> $compte
|
||||
*/
|
||||
private function poserAttente(array $compte, string $origine): void
|
||||
{
|
||||
Session::start();
|
||||
// Regénération de l'identifiant AVANT de poser l'attente : un identifiant
|
||||
// de session imposé par un tiers ne doit pas porter cette étape.
|
||||
session_regenerate_id(true);
|
||||
$_SESSION[self::ATTENTE_KEY] = [
|
||||
'compte_id' => (int) $compte['id_compte'],
|
||||
'libelle' => (string) $compte['identite_libelle'],
|
||||
'definition' => !CompteModel::motDePasseDefini($compte),
|
||||
'origine' => $origine,
|
||||
'expire' => time() + self::ATTENTE_SECONDES,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attente en cours, ou null si elle est absente ou périmée.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function attente(): ?array
|
||||
{
|
||||
Session::start();
|
||||
$attente = $_SESSION[self::ATTENTE_KEY] ?? null;
|
||||
if (!is_array($attente) || (int) ($attente['expire'] ?? 0) < time()) {
|
||||
$this->oublierAttente();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $attente;
|
||||
}
|
||||
|
||||
private function oublierAttente(): void
|
||||
{
|
||||
Session::start();
|
||||
unset($_SESSION[self::ATTENTE_KEY]);
|
||||
}
|
||||
|
||||
/** « definition » au premier accès, « verification » ensuite. */
|
||||
private function etapeAttendue(): string
|
||||
{
|
||||
return ($this->attente()['definition'] ?? false) ? 'definition' : 'verification';
|
||||
}
|
||||
|
||||
/** Jeton CSRF du formulaire, vérifié à la main (données non JSON). */
|
||||
private function csrfValide(): bool
|
||||
{
|
||||
$provided = (string) ($_POST['csrf_token'] ?? '');
|
||||
|
||||
return $provided !== '' && hash_equals(Csrf::ensureToken(), $provided);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\PdfResponse;
|
||||
use App\Core\Request;
|
||||
@@ -215,6 +216,14 @@ final class StructureController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'STRUCTURE_CREEE',
|
||||
'structure',
|
||||
isset($structure['id_structure']) ? (int) $structure['id_structure'] : null,
|
||||
sprintf('Création du lieu d’affectation « %s » (%s).', $name, $code),
|
||||
['code' => $code, 'type_affectation' => $typeAffectation]
|
||||
);
|
||||
|
||||
JsonResponse::send(201, [
|
||||
'message' => sprintf('Le lieu d’affectation « %s » a été ajouté.', $name),
|
||||
'structure' => $structure,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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\AgentModel;
|
||||
@@ -114,6 +115,13 @@ final class TeamController
|
||||
'pourcentage_diplomes_min' => $qualified,
|
||||
'commentaire' => trim((string) ($payload['commentaire'] ?? '')) ?: null,
|
||||
]);
|
||||
Journal::ecriture(
|
||||
'EQUIPE_CREEE',
|
||||
'equipe_pta',
|
||||
(int) $id,
|
||||
sprintf('Création de l’équipe « %s » sur le lieu #%d.', $label, (int) $structureId),
|
||||
['id_structure' => (int) $structureId, 'type_equipe' => $type]
|
||||
);
|
||||
JsonResponse::send(201, ['message' => 'L’équipe a été créée.', 'id_equipe' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, ['error' => 'Impossible de créer l’équipe.', 'details' => $e->getMessage()]);
|
||||
@@ -165,6 +173,13 @@ final class TeamController
|
||||
$function,
|
||||
trim((string) ($payload['commentaire'] ?? '')) ?: null
|
||||
);
|
||||
Journal::ecriture(
|
||||
'EQUIPE_MEMBRE_AJOUTE',
|
||||
'equipe_pta_membre',
|
||||
(int) $teamId,
|
||||
sprintf('Agent #%d ajouté à l’équipe #%d (%s).', (int) $agentId, (int) $teamId, $function),
|
||||
['id_agent' => (int) $agentId, 'fonction_equipe' => $function]
|
||||
);
|
||||
JsonResponse::send(200, ['message' => 'L’agent a été ajouté à l’équipe.']);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
@@ -192,6 +207,13 @@ final class TeamController
|
||||
JsonResponse::send(422, ['error' => 'Équipe ou statut invalide.']);
|
||||
}
|
||||
$this->teamModel->setStatus((int) $teamId, $status);
|
||||
Journal::ecriture(
|
||||
'EQUIPE_STATUT_MODIFIE',
|
||||
'equipe_pta',
|
||||
(int) $teamId,
|
||||
sprintf('Équipe #%d passée au statut %s.', (int) $teamId, $status),
|
||||
['statut' => $status]
|
||||
);
|
||||
JsonResponse::send(200, ['message' => 'Le statut de l’équipe a été mis à jour.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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\VacationModel;
|
||||
@@ -177,6 +178,13 @@ final class VacationController
|
||||
}
|
||||
|
||||
$count = $this->vacationModel->saveMany($periods);
|
||||
Journal::ecriture(
|
||||
'VACANCES_ENREGISTREES',
|
||||
'periode_vacance',
|
||||
null,
|
||||
sprintf('%d période(s) de vacances enregistrée(s) ou mise(s) à jour.', $count),
|
||||
['nombre' => $count]
|
||||
);
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'count' => $count,
|
||||
@@ -208,6 +216,13 @@ final class VacationController
|
||||
JsonResponse::send(404, ['error' => 'Période introuvable.']);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'VACANCES_SUPPRIMEES',
|
||||
'periode_vacance',
|
||||
(int) $id,
|
||||
sprintf('Période de vacances #%d supprimée.', (int) $id)
|
||||
);
|
||||
|
||||
JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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\Core\Security;
|
||||
@@ -200,6 +201,14 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_ENREGISTREE',
|
||||
'modele_semaine_agent',
|
||||
(int) $template['id_modele'],
|
||||
sprintf('Semaine type « %s » enregistrée depuis l’éditeur pour l’agent #%d.', (string) $template['nom'], (int) $agentId),
|
||||
['id_agent' => (int) $agentId, 'nombre_creneaux' => (int) $template['nombre_creneaux'], 'remplacement' => $replace]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'template' => [
|
||||
@@ -259,6 +268,14 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_ENREGISTREE',
|
||||
'modele_semaine_agent',
|
||||
isset($template['id_modele']) ? (int) $template['id_modele'] : null,
|
||||
sprintf('Semaine type « %s » enregistrée depuis une semaine planifiée pour l’agent #%d.', $name, (int) $agentId),
|
||||
['id_agent' => (int) $agentId, 'nombre_creneaux' => (int) $template['nombre_creneaux'], 'remplacement' => $replace]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'template' => $template,
|
||||
@@ -290,12 +307,18 @@ final class WeekTemplateController
|
||||
if (!$templateId || !$agentId || $week === null || !in_array($mode, ['merge', 'replace'], true)) {
|
||||
JsonResponse::send(422, ['error' => 'Semaine type, agent, semaine ou mode d’import invalide.']);
|
||||
}
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
[$year, $weekNumber] = $week;
|
||||
$lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
|
||||
$this->assertDatesWithinContract($agent, array_map(
|
||||
static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'),
|
||||
range(0, 4)
|
||||
));
|
||||
|
||||
try {
|
||||
[$year, $weekNumber] = $week;
|
||||
$result = $this->templateModel->applyToWeek((int) $templateId, (int) $agentId, $year, $weekNumber, $mode);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(409, ['error' => $e->getMessage()]);
|
||||
@@ -318,6 +341,27 @@ final class WeekTemplateController
|
||||
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_APPLIQUEE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf(
|
||||
'Semaine type « %s » appliquée à l’agent #%d sur la semaine %d de %d : %d créneau(x).',
|
||||
(string) $result['template']['nom'],
|
||||
(int) $agentId,
|
||||
$weekNumber,
|
||||
$year,
|
||||
(int) $result['inserted']
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'id_modele' => (int) $templateId,
|
||||
'annee' => $year,
|
||||
'semaine' => $weekNumber,
|
||||
'mode' => $mode,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -368,9 +412,11 @@ final class WeekTemplateController
|
||||
) {
|
||||
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
|
||||
}
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
$this->assertPeriodWithinContract($agent, $startDate, $endDate);
|
||||
|
||||
try {
|
||||
$preview = $this->templateModel->previewPeriod(
|
||||
@@ -435,9 +481,11 @@ final class WeekTemplateController
|
||||
) {
|
||||
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
|
||||
}
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
$this->assertPeriodWithinContract($agent, $startDate, $endDate);
|
||||
|
||||
try {
|
||||
$result = $this->templateModel->applyToPeriod(
|
||||
@@ -499,6 +547,27 @@ final class WeekTemplateController
|
||||
$quotas[(string) $quota['date_debut_periode']] = $quota;
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_APPLIQUEE_PERIODE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf(
|
||||
'Semaine type #%d appliquée à l’agent #%d du %s au %s.',
|
||||
(int) $templateId,
|
||||
(int) $agentId,
|
||||
$startDate,
|
||||
$endDate
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'id_modele' => (int) $templateId,
|
||||
'date_debut' => $startDate,
|
||||
'date_fin' => $endDate,
|
||||
'mode' => $mode,
|
||||
'filtre_periode' => $periodFilter,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -533,6 +602,14 @@ final class WeekTemplateController
|
||||
JsonResponse::send(404, ['error' => 'Semaine type introuvable.']);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_SUPPRIMEE',
|
||||
'modele_semaine_agent',
|
||||
(int) $templateId,
|
||||
sprintf('Semaine type #%d supprimée pour l’agent #%d.', (int) $templateId, (int) $agentId),
|
||||
['id_agent' => (int) $agentId]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']);
|
||||
}
|
||||
|
||||
@@ -541,6 +618,51 @@ final class WeekTemplateController
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
|
||||
*/
|
||||
/**
|
||||
* Refuse toute affectation hors des dates de contrat de l'agent (V7.15).
|
||||
*
|
||||
* Copie assumée de PlanningController::assertDatesWithinContract() : les deux
|
||||
* contrôleurs n'ont pas de base commune, et la règle doit s'appliquer des
|
||||
* deux côtés. Le MESSAGE, lui, est construit une seule fois par AgentModel —
|
||||
* c'est lui qui doit rester unique, pas la garde.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
* @param array<int, string> $dates
|
||||
*/
|
||||
private function assertDatesWithinContract(array $agent, array $dates): void
|
||||
{
|
||||
$hors = AgentModel::datesOutsideContract($agent, $dates);
|
||||
if ($hors === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonResponse::send(422, [
|
||||
'error' => AgentModel::contractViolationMessage($agent, $hors),
|
||||
'code' => 'HORS_CONTRAT',
|
||||
'dates' => $hors,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Même règle, pour une PÉRIODE entière.
|
||||
*
|
||||
* Seules les bornes sont contrôlées, et c'est suffisant : le contrat est un
|
||||
* intervalle continu, donc une période dont les deux extrémités y tombent y
|
||||
* est entièrement contenue. Contrôler chacun des jours d'une duplication
|
||||
* annuelle coûterait 365 comparaisons pour la même réponse.
|
||||
*
|
||||
* Le refus porte volontairement sur la période DEMANDÉE et non sur les seuls
|
||||
* jours réellement écrits : personne ne doit avoir à deviner qu'une
|
||||
* duplication « du 1er janvier au 31 décembre » n'a rien produit en novembre
|
||||
* parce que le contrat s'arrêtait en octobre.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
*/
|
||||
private function assertPeriodWithinContract(array $agent, string $startDate, string $endDate): void
|
||||
{
|
||||
$this->assertDatesWithinContract($agent, [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
Reference in New Issue
Block a user