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)) {
|
||||
|
||||
@@ -22,9 +22,17 @@ final class Access
|
||||
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
|
||||
/** Périmètre complet ; seul rôle habilité à valider un planning. */
|
||||
public const ROLE_SERVICE = 'SERVICE_ENFANCE';
|
||||
/**
|
||||
* Administration technique (V7.11) : comptes d'accès, jetons et journal.
|
||||
* Aucun droit métier — la DSI ne voit ni les plannings, ni les agents.
|
||||
*/
|
||||
public const ROLE_DSI = 'DSI';
|
||||
|
||||
private const SESSION_KEY = 'pta_access';
|
||||
|
||||
/** @var array<int, string> Les quatre rôles connus de l'application. */
|
||||
private const ROLES = [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE, self::ROLE_DSI];
|
||||
|
||||
/**
|
||||
* Caches par requête : les vues d'accès sont interrogées une seule fois par
|
||||
* rôle et par requête HTTP. Rien n'est mis en cache entre deux requêtes, pour
|
||||
@@ -37,6 +45,19 @@ final class Access
|
||||
/** @var array<string, array<int, string>> */
|
||||
private array $pageCache = [];
|
||||
|
||||
/**
|
||||
* Compte d'accès de la session, relu UNE FOIS par requête.
|
||||
*
|
||||
* Il est relu à chaque requête et non conservé en session : c'est ce qui
|
||||
* fait qu'un jeton désactivé par la DSI coupe l'accès à la requête suivante,
|
||||
* sans attendre l'expiration de la session.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $accountCache = null;
|
||||
|
||||
private bool $accountLoaded = false;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
Session::start();
|
||||
@@ -50,22 +71,32 @@ final class Access
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
$role = $this->role();
|
||||
if ($role === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Depuis la V7.11, toute session est adossée à un compte d'accès actif :
|
||||
// pas de compte vivant, pas de session. Un jeton révoqué, un agent
|
||||
// désactivé ou un lieu fermé referment donc l'accès immédiatement.
|
||||
$compte = $this->accountRecord();
|
||||
if ($compte === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($role === self::ROLE_AGENT) {
|
||||
return $this->agentId() !== null && $this->agentRecord() !== null;
|
||||
}
|
||||
if ($role === self::ROLE_RESPONSABLE) {
|
||||
return $this->structureId() !== null && $this->structureRecord() !== null;
|
||||
}
|
||||
return $role === self::ROLE_SERVICE;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Rôle en session, ou null si aucun rôle valide n'est sélectionné. */
|
||||
public function role(): ?string
|
||||
{
|
||||
$role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? '')));
|
||||
return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)
|
||||
? $role
|
||||
: null;
|
||||
return in_array($role, self::ROLES, true) ? $role : null;
|
||||
}
|
||||
|
||||
/** Agent sélectionné (rôle AGENT uniquement), ou null. */
|
||||
@@ -100,50 +131,87 @@ final class Access
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre la sélection de rôle en session.
|
||||
* Ouvre la session de travail à partir d'un compte d'accès authentifié.
|
||||
*
|
||||
* ATTENTION : ceci est un sélecteur, PAS une authentification — l'utilisateur
|
||||
* choisit librement son identité. C'est la faille ouverte la plus importante
|
||||
* du projet ; un passage en production doit remplacer cet appel par une
|
||||
* authentification réelle, sans toucher au reste de la mécanique de droits.
|
||||
* DEPUIS LA V7.11, C'EST LE SEUL CHEMIN D'ENTRÉE : l'ancien sélecteur libre
|
||||
* (select()) a disparu, une session ne peut plus naître que de la
|
||||
* présentation d'un jeton valide. Le rôle, l'agent et le lieu ne sont plus
|
||||
* choisis par l'utilisateur mais LUS SUR LE COMPTE — c'est ce qui rend la
|
||||
* mécanique de droits opposable.
|
||||
*
|
||||
* @throws \DomainException Si le rôle est inconnu ou l'identité inactive.
|
||||
* La vérification du jeton elle-même appartient à CompteModel : cette
|
||||
* méthode reçoit un compte déjà authentifié.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte.
|
||||
*/
|
||||
public function select(string $role, ?int $agentId = null, ?int $structureId = null): void
|
||||
public function openSession(array $compte): void
|
||||
{
|
||||
$role = strtoupper(trim($role));
|
||||
if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) {
|
||||
throw new \DomainException('Rôle invalide.');
|
||||
}
|
||||
|
||||
$selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null];
|
||||
|
||||
if ($role === self::ROLE_AGENT) {
|
||||
if (!$agentId || !$this->activeAgentExists($agentId)) {
|
||||
throw new \DomainException('Sélectionnez un agent actif.');
|
||||
}
|
||||
$selection['agent_id'] = $agentId;
|
||||
} elseif ($role === self::ROLE_RESPONSABLE) {
|
||||
if (!$structureId || !$this->activeStructureExists($structureId)) {
|
||||
throw new \DomainException('Sélectionnez un lieu d’affectation actif.');
|
||||
}
|
||||
$selection['structure_id'] = $structureId;
|
||||
}
|
||||
|
||||
// Regénération de l'identifiant de session à chaque changement de rôle :
|
||||
// parade classique à la fixation de session.
|
||||
// Regénération de l'identifiant de session à chaque ouverture : parade
|
||||
// classique à la fixation de session.
|
||||
session_regenerate_id(true);
|
||||
$_SESSION[self::SESSION_KEY] = $selection;
|
||||
// Le jeton CSRF suit le cycle de vie de la session : un changement de
|
||||
// rôle invalide le jeton précédent.
|
||||
|
||||
$_SESSION[self::SESSION_KEY] = [
|
||||
'role' => (string) $compte['role'],
|
||||
'agent_id' => isset($compte['id_agent']) && $compte['id_agent'] !== null
|
||||
? (int) $compte['id_agent']
|
||||
: null,
|
||||
'structure_id' => isset($compte['id_structure']) && $compte['id_structure'] !== null
|
||||
? (int) $compte['id_structure']
|
||||
: null,
|
||||
'compte_id' => (int) $compte['id_compte'],
|
||||
// Libellé figé pour le journal : il évite une jointure à chaque trace.
|
||||
'acteur' => (string) ($compte['identite_libelle'] ?? $compte['libelle'] ?? ''),
|
||||
];
|
||||
|
||||
// Le jeton CSRF suit le cycle de vie de la session : une nouvelle
|
||||
// connexion invalide le jeton précédent.
|
||||
unset($_SESSION['csrf_token']);
|
||||
$this->accountCache = null;
|
||||
$this->accountLoaded = false;
|
||||
}
|
||||
|
||||
/** Abandonne la sélection courante (« changer de rôle »). */
|
||||
/** Ferme la session de travail (déconnexion). */
|
||||
public function clear(): void
|
||||
{
|
||||
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
|
||||
session_regenerate_id(true);
|
||||
$this->accountCache = null;
|
||||
$this->accountLoaded = false;
|
||||
}
|
||||
|
||||
/** Identifiant du compte d'accès de la session, ou null. */
|
||||
public function accountId(): ?int
|
||||
{
|
||||
$value = filter_var($_SESSION[self::SESSION_KEY]['compte_id'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
return $value === false ? null : (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantané de session destiné au JOURNAL, lisible sans instancier Access
|
||||
* ni ouvrir de connexion.
|
||||
*
|
||||
* Il expose la clé de session privée sous une forme stable : Journal (et lui
|
||||
* seul) a besoin de connaître l'acteur courant depuis un contexte statique,
|
||||
* sans pouvoir dépendre d'une instance.
|
||||
*
|
||||
* @return array{id_compte: int|null, role: string|null, acteur: string|null}
|
||||
*/
|
||||
public static function sessionSnapshot(): array
|
||||
{
|
||||
$selection = $_SESSION[self::SESSION_KEY] ?? [];
|
||||
$compteId = filter_var($selection['compte_id'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
$role = strtoupper(trim((string) ($selection['role'] ?? '')));
|
||||
$acteur = trim((string) ($selection['acteur'] ?? ''));
|
||||
|
||||
return [
|
||||
'id_compte' => $compteId === false ? null : (int) $compteId,
|
||||
'role' => in_array($role, self::ROLES, true) ? $role : null,
|
||||
'acteur' => $acteur === '' ? null : $acteur,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +232,7 @@ final class Access
|
||||
'label' => $this->roleLabel(),
|
||||
'agent_id' => $this->agentId(),
|
||||
'structure_id' => $this->structureId(),
|
||||
'account_id' => $this->accountId(),
|
||||
'permissions' => $permissions,
|
||||
];
|
||||
|
||||
@@ -182,6 +251,14 @@ final class Access
|
||||
$profile['structure_name'] = (string) $structure['nom'];
|
||||
$profile['structure_code'] = (string) $structure['code'];
|
||||
}
|
||||
} else {
|
||||
// Service Enfance et DSI ne portent aucune identité métier : le
|
||||
// libellé affiché est celui du compte (« Direction des systèmes
|
||||
// d'information », « Service Enfance — Camille »…).
|
||||
$compte = $this->accountRecord();
|
||||
if ($compte !== null) {
|
||||
$profile['label'] = (string) $compte['identite_libelle'];
|
||||
}
|
||||
}
|
||||
|
||||
return $profile;
|
||||
@@ -269,6 +346,9 @@ final class Access
|
||||
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
|
||||
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
|
||||
self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php',
|
||||
// La DSI n'a qu'une page ; le repli sur role.php évite la boucle de
|
||||
// redirection si la migration V7.11 n'est pas jouée.
|
||||
self::ROLE_DSI => $this->canAccessPage('dsi') ? 'dsi.php' : 'role.php',
|
||||
default => 'role.php',
|
||||
};
|
||||
}
|
||||
@@ -286,6 +366,11 @@ final class Access
|
||||
exit;
|
||||
}
|
||||
if (!$this->canAccessPage($page)) {
|
||||
Journal::refus(
|
||||
'PAGE_REFUSEE',
|
||||
sprintf('Accès refusé à la page « %s ».', $page),
|
||||
['contexte' => ['page' => $page]]
|
||||
);
|
||||
header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302);
|
||||
exit;
|
||||
}
|
||||
@@ -300,19 +385,58 @@ final class Access
|
||||
public function requireRoles(array $roles): void
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']);
|
||||
$this->refuser(401, 'SESSION_ABSENTE', 'Appel d’API sans session valide.', [
|
||||
'endpoint' => $this->endpointCourant(),
|
||||
]);
|
||||
JsonResponse::send(401, ['error' => 'Connectez-vous avec votre jeton d’accès avant de continuer.', 'code' => 'ROLE_REQUIRED']);
|
||||
}
|
||||
if (!in_array($this->role(), $roles, true)) {
|
||||
$this->refuser(403, 'ROLE_INSUFFISANT', 'Rôle insuffisant pour cette action.', [
|
||||
'endpoint' => $this->endpointCourant(),
|
||||
'roles_attendus' => array_values($roles),
|
||||
]);
|
||||
JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Journalise un refus d'API. Méthode privée volontairement minuscule : elle
|
||||
* n'existe que pour que les gardes ci-dessous tiennent en deux lignes.
|
||||
*
|
||||
* @param array<string, mixed> $contexte
|
||||
*/
|
||||
private function refuser(int $status, string $action, string $description, array $contexte = []): void
|
||||
{
|
||||
Journal::refus($action, $description, [
|
||||
'contexte' => array_merge(['statut_http' => $status], $contexte),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Chemin de l'endpoint appelé, pour situer un refus dans le journal. */
|
||||
private function endpointCourant(): string
|
||||
{
|
||||
$chemin = (string) ($_SERVER['SCRIPT_NAME'] ?? '');
|
||||
return $chemin !== '' ? basename($chemin) : 'inconnu';
|
||||
}
|
||||
|
||||
/** Réservé au Service Enfance : validation, référentiels, administration. */
|
||||
public function requireService(): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_SERVICE]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Réservé à la DSI : gestion des comptes d'accès et lecture du journal.
|
||||
*
|
||||
* Cette garde double le contrôle de page : la page « dsi » n'est autorisée
|
||||
* qu'au rôle DSI dans acces_role_page, mais un endpoint doit rester sûr même
|
||||
* si quelqu'un ouvrait ce droit à un autre rôle par une requête SQL.
|
||||
*/
|
||||
public function requireDsi(): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_DSI]);
|
||||
}
|
||||
|
||||
/** Rôles autorisés à modifier un brouillon (l'agent est en lecture seule). */
|
||||
public function requireDraftEditor(): void
|
||||
{
|
||||
@@ -324,6 +448,7 @@ final class Access
|
||||
{
|
||||
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
|
||||
if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) {
|
||||
$this->refuser(403, 'PERIMETRE_LIEU', 'Consultation d’un lieu hors périmètre.', ['id_structure' => $structureId]);
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']);
|
||||
}
|
||||
}
|
||||
@@ -333,6 +458,7 @@ final class Access
|
||||
{
|
||||
$this->requireDraftEditor();
|
||||
if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) {
|
||||
$this->refuser(403, 'PERIMETRE_LIEU', 'Modification d’un lieu hors périmètre.', ['id_structure' => $structureId]);
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']);
|
||||
}
|
||||
}
|
||||
@@ -354,6 +480,7 @@ final class Access
|
||||
if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) {
|
||||
return;
|
||||
}
|
||||
$this->refuser(403, 'PERIMETRE_AGENT', 'Consultation d’un agent hors périmètre.', ['id_agent' => $agentId]);
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']);
|
||||
}
|
||||
|
||||
@@ -412,23 +539,51 @@ final class Access
|
||||
self::ROLE_AGENT => 'Agent',
|
||||
self::ROLE_RESPONSABLE => 'Responsable de structure',
|
||||
self::ROLE_SERVICE => 'Service Enfance',
|
||||
self::ROLE_DSI => 'DSI',
|
||||
default => 'Rôle non sélectionné',
|
||||
};
|
||||
}
|
||||
|
||||
/** Les vues v_acces_identite_* ne listent que les identités ACTIVES. */
|
||||
private function activeAgentExists(int $agentId): bool
|
||||
/**
|
||||
* Compte d'accès de la session, relu une fois par requête.
|
||||
*
|
||||
* La vue ne renvoie le compte que s'il est actif ET que l'identité qu'il
|
||||
* porte l'est aussi (`identite_active`) : désactiver un agent suffit donc à
|
||||
* lui couper l'accès, sans avoir à toucher à son jeton.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function accountRecord(): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $agentId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
if ($this->accountLoaded) {
|
||||
return $this->accountCache;
|
||||
}
|
||||
|
||||
private function activeStructureExists(int $structureId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $structureId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
$this->accountLoaded = true;
|
||||
$this->accountCache = null;
|
||||
|
||||
$compteId = $this->accountId();
|
||||
if ($compteId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_compte, role, libelle, id_agent, id_structure, identite_libelle
|
||||
FROM v_acces_compte
|
||||
WHERE id_compte = :id AND actif = 1 AND identite_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
$compte = $stmt->fetch();
|
||||
|
||||
// Le rôle de la session doit toujours correspondre à celui du compte :
|
||||
// une session forgée ou survivant à un changement de rôle du compte est
|
||||
// refusée plutôt que réparée.
|
||||
if ($compte !== false && (string) $compte['role'] === $this->role()) {
|
||||
$this->accountCache = $compte;
|
||||
}
|
||||
|
||||
return $this->accountCache;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,12 @@ final class Csrf
|
||||
$provided = (string) ($payload['csrf_token'] ?? '');
|
||||
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
// Un jeton CSRF absent ou faux est le plus souvent une session
|
||||
// expirée, parfois une tentative réelle : dans les deux cas la trace
|
||||
// a sa place dans le journal (V7.11).
|
||||
Journal::refus('CSRF_INVALIDE', 'Jeton de sécurité absent ou invalide.', [
|
||||
'contexte' => ['endpoint' => basename((string) ($_SERVER['SCRIPT_NAME'] ?? 'inconnu'))],
|
||||
]);
|
||||
// send() termine le script : rien ne s'exécute après ce point.
|
||||
JsonResponse::send(403, ['error' => 'Jeton de sécurité invalide. Rechargez la page.']);
|
||||
}
|
||||
|
||||
182
app/Core/Journal.php
Normal file
182
app/Core/Journal.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Journal d'activité — écriture des traces dans journal_activite (V7.11).
|
||||
*
|
||||
* Trois familles d'événements sont tracées, et seulement celles-là :
|
||||
* - CONNEXION : entrée par jeton, échec de jeton, déconnexion ;
|
||||
* - ECRITURE : toute modification en base, avec la table et l'identifiant
|
||||
* visés ;
|
||||
* - ACCES_REFUSE : page interdite, rôle insuffisant, jeton CSRF invalide.
|
||||
* S'y ajoutent ADMINISTRATION (actions de la DSI sur les comptes et le journal)
|
||||
* et SYSTEME, réservé aux incidents techniques.
|
||||
* Les LECTURES ne sont pas journalisées : elles représenteraient l'essentiel du
|
||||
* volume pour une valeur d'audit très faible.
|
||||
*
|
||||
* DEUX RÈGLES ABSOLUES :
|
||||
* 1. écrire dans le journal ne doit JAMAIS faire échouer l'action journalisée.
|
||||
* Toute erreur d'insertion est avalée et renvoyée dans le journal serveur
|
||||
* (error_log). Un journal indisponible dégrade l'audit, il ne bloque pas la
|
||||
* planification des agents.
|
||||
* 2. la trace est FIGÉE : rôle et libellé de l'acteur sont copiés au moment de
|
||||
* l'action. Renommer un agent ou supprimer son compte ne réécrit pas
|
||||
* l'histoire (d'où le ON DELETE SET NULL de la clé étrangère).
|
||||
*
|
||||
* La connexion PDO est posée une fois par app/bootstrap.php
|
||||
* (Journal::useConnection()) : les appelants — y compris les gardes statiques
|
||||
* comme Csrf — n'ont ainsi rien à transporter.
|
||||
*/
|
||||
final class Journal
|
||||
{
|
||||
public const CONNEXION = 'CONNEXION';
|
||||
public const ECRITURE = 'ECRITURE';
|
||||
public const ACCES_REFUSE = 'ACCES_REFUSE';
|
||||
public const ADMINISTRATION = 'ADMINISTRATION';
|
||||
public const SYSTEME = 'SYSTEME';
|
||||
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
/**
|
||||
* Enregistre la connexion utilisée pour écrire les traces.
|
||||
* Appelé par app/bootstrap.php, donc valable pour toute la requête.
|
||||
*/
|
||||
public static function useConnection(PDO $pdo): void
|
||||
{
|
||||
self::$pdo = $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace une entrée, une sortie ou un échec d'authentification.
|
||||
*
|
||||
* @param array<string, mixed> $options Voir enregistrer().
|
||||
*/
|
||||
public static function connexion(string $action, array $options = []): void
|
||||
{
|
||||
self::enregistrer(self::CONNEXION, $action, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace une modification en base.
|
||||
*
|
||||
* @param string $action Code stable, ex. « PLANNING_VALIDE ».
|
||||
* @param string $cibleTable Table principale touchée.
|
||||
* @param int|null $cibleId Identifiant de la ligne visée.
|
||||
* @param string $description Phrase lisible dans la page DSI.
|
||||
* @param array<string, mixed> $contexte Détail structuré (semaine, lieu…).
|
||||
*/
|
||||
public static function ecriture(
|
||||
string $action,
|
||||
string $cibleTable,
|
||||
?int $cibleId,
|
||||
string $description,
|
||||
array $contexte = []
|
||||
): void {
|
||||
self::enregistrer(self::ECRITURE, $action, [
|
||||
'cible_table' => $cibleTable,
|
||||
'cible_id' => $cibleId,
|
||||
'description' => $description,
|
||||
'contexte' => $contexte,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace un refus d'accès. Le résultat vaut REFUS, jamais SUCCES.
|
||||
*
|
||||
* @param array<string, mixed> $options Voir enregistrer().
|
||||
*/
|
||||
public static function refus(string $action, string $description, array $options = []): void
|
||||
{
|
||||
self::enregistrer(self::ACCES_REFUSE, $action, array_merge($options, [
|
||||
'description' => $description,
|
||||
'resultat' => 'REFUS',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace une action d'administration technique (comptes, jetons, journal).
|
||||
*
|
||||
* @param array<string, mixed> $options Voir enregistrer().
|
||||
*/
|
||||
public static function administration(string $action, array $options = []): void
|
||||
{
|
||||
self::enregistrer(self::ADMINISTRATION, $action, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit une ligne de journal. Ne lève jamais d'exception.
|
||||
*
|
||||
* L'acteur est déduit de la session, sauf si l'appelant le fournit : c'est
|
||||
* indispensable à la connexion, où la trace est écrite AVANT que la session
|
||||
* porte le compte (ou pour un jeton refusé, qui n'ouvre aucune session).
|
||||
*
|
||||
* @param string $categorie Une des constantes de classe.
|
||||
* @param string $action Code stable de l'action.
|
||||
* @param array<string, mixed> $options resultat, cible_table, cible_id,
|
||||
* description, contexte, id_compte,
|
||||
* role, acteur.
|
||||
*/
|
||||
public static function enregistrer(string $categorie, string $action, array $options = []): void
|
||||
{
|
||||
if (self::$pdo === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$session = Access::sessionSnapshot();
|
||||
$contexte = $options['contexte'] ?? [];
|
||||
|
||||
$stmt = self::$pdo->prepare(
|
||||
'INSERT INTO journal_activite
|
||||
(categorie, action, resultat, id_compte, role, acteur,
|
||||
cible_table, cible_id, description, contexte,
|
||||
adresse_ip, agent_utilisateur)
|
||||
VALUES
|
||||
(:categorie, :action, :resultat, :id_compte, :role, :acteur,
|
||||
:cible_table, :cible_id, :description, :contexte,
|
||||
:adresse_ip, :agent_utilisateur)'
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
'categorie' => $categorie,
|
||||
'action' => mb_substr($action, 0, 60),
|
||||
'resultat' => $options['resultat'] ?? 'SUCCES',
|
||||
'id_compte' => $options['id_compte'] ?? $session['id_compte'],
|
||||
'role' => $options['role'] ?? $session['role'],
|
||||
'acteur' => self::tronquer($options['acteur'] ?? $session['acteur'], 180),
|
||||
'cible_table' => self::tronquer($options['cible_table'] ?? null, 64),
|
||||
'cible_id' => $options['cible_id'] ?? null,
|
||||
'description' => self::tronquer($options['description'] ?? null, 500),
|
||||
// JSON_INVALID_UTF8_SUBSTITUTE : une donnée mal encodée ne doit
|
||||
// pas faire échouer l'encodage, donc la trace tout entière.
|
||||
'contexte' => $contexte === []
|
||||
? null
|
||||
: json_encode($contexte, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
|
||||
// REMOTE_ADDR et non X-Forwarded-For : cet en-tête est fourni par
|
||||
// le client et falsifiable. Derrière un reverse proxy, c'est au
|
||||
// proxy de réécrire REMOTE_ADDR.
|
||||
'adresse_ip' => self::tronquer($_SERVER['REMOTE_ADDR'] ?? null, 45),
|
||||
'agent_utilisateur' => self::tronquer($_SERVER['HTTP_USER_AGENT'] ?? null, 255),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Règle 1 : le journal ne casse jamais l'action journalisée.
|
||||
error_log(sprintf('[PTA][journal] écriture impossible (%s) : %s', $action, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Tronque à la longueur de la colonne, en préservant les caractères accentués. */
|
||||
private static function tronquer(?string $valeur, int $longueur): ?string
|
||||
{
|
||||
if ($valeur === null) {
|
||||
return null;
|
||||
}
|
||||
$valeur = trim($valeur);
|
||||
return $valeur === '' ? null : mb_substr($valeur, 0, $longueur);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,149 @@ final class AgentModel extends BaseModel
|
||||
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bornes du contrat, telles qu'elles servent aux contrôles d'affectation.
|
||||
*
|
||||
* Les deux bornes sont FACULTATIVES et n'ont pas le même sens quand elles
|
||||
* manquent :
|
||||
* - pas de date de début : contrat d'origine inconnue (fiche ancienne), on
|
||||
* n'oppose alors aucune borne basse plutôt que d'inventer une date ;
|
||||
* - pas de date de fin : contrat à durée indéterminée, la borne haute
|
||||
* n'existe pas.
|
||||
* Dans les deux cas, l'absence ouvre — elle ne ferme jamais.
|
||||
*
|
||||
* @param array<string, mixed> $agent Fiche agent, telle que renvoyée par
|
||||
* findActive() ou allActive().
|
||||
*
|
||||
* @return array{0: string|null, 1: string|null} [début, fin] au format AAAA-MM-JJ.
|
||||
*/
|
||||
public static function contractRange(array $agent): array
|
||||
{
|
||||
$debut = trim((string) ($agent['date_debut_contrat'] ?? ''));
|
||||
$fin = trim((string) ($agent['date_fin_contrat'] ?? ''));
|
||||
|
||||
return [$debut !== '' ? $debut : null, $fin !== '' ? $fin : null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la date tombe dans les bornes du contrat.
|
||||
*
|
||||
* Comparaison de chaînes « AAAA-MM-JJ » : ce format se compare
|
||||
* lexicographiquement dans le même ordre que chronologiquement, ce qui évite
|
||||
* de construire deux objets DateTime par créneau contrôlé.
|
||||
*/
|
||||
public static function contractCoversDate(array $agent, string $date): bool
|
||||
{
|
||||
[$debut, $fin] = self::contractRange($agent);
|
||||
|
||||
if ($debut !== null && $date < $debut) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !($fin !== null && $date > $fin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre les dates qui tombent hors contrat, dédoublonnées et triées.
|
||||
*
|
||||
* @param array<int, string> $dates
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function datesOutsideContract(array $agent, array $dates): array
|
||||
{
|
||||
$hors = [];
|
||||
foreach ($dates as $date) {
|
||||
$date = trim((string) $date);
|
||||
if ($date !== '' && !self::contractCoversDate($agent, $date)) {
|
||||
$hors[$date] = true;
|
||||
}
|
||||
}
|
||||
$hors = array_keys($hors);
|
||||
sort($hors);
|
||||
|
||||
return $hors;
|
||||
}
|
||||
|
||||
/** « du 01/09/2026 au 31/08/2027 », « à partir du 01/09/2026 », « jusqu'au … ». */
|
||||
public static function contractPeriodLabel(array $agent): string
|
||||
{
|
||||
[$debut, $fin] = self::contractRange($agent);
|
||||
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
|
||||
|
||||
if ($debut !== null && $fin !== null) {
|
||||
return sprintf('du %s au %s', $lisible($debut), $lisible($fin));
|
||||
}
|
||||
if ($debut !== null) {
|
||||
return sprintf('à partir du %s', $lisible($debut));
|
||||
}
|
||||
if ($fin !== null) {
|
||||
return sprintf('jusqu’au %s', $lisible($fin));
|
||||
}
|
||||
|
||||
return 'sans date de contrat renseignée';
|
||||
}
|
||||
|
||||
/**
|
||||
* Message de refus commun à tous les points d'écriture, pour que
|
||||
* l'utilisateur lise la même phrase quel que soit l'écran qui l'a produit.
|
||||
*
|
||||
* @param array<int, string> $datesHorsContrat
|
||||
*/
|
||||
public static function contractViolationMessage(array $agent, array $datesHorsContrat): string
|
||||
{
|
||||
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
|
||||
$exemples = array_map($lisible, array_slice($datesHorsContrat, 0, 3));
|
||||
if (count($datesHorsContrat) > 3) {
|
||||
$exemples[] = sprintf('et %d autre(s)', count($datesHorsContrat) - 3);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s %s n’est pas sous contrat à cette période : son contrat court %s. Date(s) refusée(s) : %s.',
|
||||
(string) ($agent['prenom'] ?? ''),
|
||||
(string) ($agent['nom'] ?? ''),
|
||||
self::contractPeriodLabel($agent),
|
||||
implode(', ', $exemples)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux déjà planifiés en dehors d'une fenêtre de contrat.
|
||||
*
|
||||
* Sert au contrôle MIROIR de l'affectation : raccourcir un contrat ne doit
|
||||
* pas laisser derrière lui des affectations orphelines. Interroge donc les
|
||||
* créneaux, pas les plannings — un planning peut être à cheval sur la borne.
|
||||
*
|
||||
* @return array{nombre: int, premiere: string|null, derniere: string|null}
|
||||
*/
|
||||
public function entriesOutsideContract(int $agentId, ?string $debut, ?string $fin): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) AS nombre, MIN(c.date_jour) AS premiere, MAX(c.date_jour) AS derniere
|
||||
FROM creneau_horaire c
|
||||
JOIN planning p ON p.id_planning = c.id_planning
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND ((:debut IS NOT NULL AND c.date_jour < :debut_compare)
|
||||
OR (:fin IS NOT NULL AND c.date_jour > :fin_compare))'
|
||||
);
|
||||
// Chaque paramètre nommé est répété sous deux noms : PDO tourne sans
|
||||
// émulation, un même nom ne peut pas apparaître deux fois.
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'debut' => $debut,
|
||||
'debut_compare' => $debut,
|
||||
'fin' => $fin,
|
||||
'fin_compare' => $fin,
|
||||
]);
|
||||
$ligne = $stmt->fetch() ?: [];
|
||||
|
||||
return [
|
||||
'nombre' => (int) ($ligne['nombre'] ?? 0),
|
||||
'premiere' => $ligne['premiere'] ?? null,
|
||||
'derniere' => $ligne['derniere'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection commune à toutes les lectures d'agent, complétée par la clause
|
||||
* WHERE de chaque appelant.
|
||||
@@ -375,6 +518,10 @@ final class AgentModel extends BaseModel
|
||||
* bindValue() explicite ici (et non un tableau passé à execute()) car
|
||||
* `structure_id` peut être NULL et exige PDO::PARAM_NULL.
|
||||
*
|
||||
* Le nom et le prénom font partie des champs modifiables depuis la V7.13 ;
|
||||
* le MATRICULE, lui, ne l'est jamais : c'est la clé de rapprochement avec
|
||||
* les outils RH, et les plannings comme les PDF déjà produits s'y réfèrent.
|
||||
*
|
||||
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
|
||||
*
|
||||
* @throws \RuntimeException Si l'agent n'existe pas.
|
||||
@@ -395,7 +542,9 @@ final class AgentModel extends BaseModel
|
||||
|
||||
$update = $this->pdo->prepare(
|
||||
'UPDATE agent
|
||||
SET email = :email,
|
||||
SET nom = :nom,
|
||||
prenom = :prenom,
|
||||
email = :email,
|
||||
telephone = :telephone,
|
||||
adresse = :adresse,
|
||||
est_diplome = :est_diplome,
|
||||
@@ -410,6 +559,8 @@ final class AgentModel extends BaseModel
|
||||
WHERE id_agent = :agent_id'
|
||||
);
|
||||
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
|
||||
$update->bindValue(':nom', $data['nom']);
|
||||
$update->bindValue(':prenom', $data['prenom']);
|
||||
$update->bindValue(':email', $data['email']);
|
||||
$update->bindValue(':telephone', $data['telephone']);
|
||||
$update->bindValue(':adresse', $data['adresse']);
|
||||
|
||||
672
app/Models/CompteModel.php
Normal file
672
app/Models/CompteModel.php
Normal file
@@ -0,0 +1,672 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Access;
|
||||
use DomainException;
|
||||
|
||||
/**
|
||||
* Comptes d'accès et jetons (V7.11).
|
||||
*
|
||||
* PRINCIPE : chaque utilisateur — agent, responsable de lieu, Service Enfance,
|
||||
* DSI — reçoit un jeton unique tiré au sort par le serveur. Il n'y a AUCUN mot
|
||||
* de passe dans PTA : rien à choisir, rien à renouveler, rien à ressaisir sur
|
||||
* un post-it.
|
||||
*
|
||||
* Le jeton n'est stocké NULLE PART en clair. La base garde son empreinte
|
||||
* SHA-256 et ses onze premiers caractères, qui servent uniquement à reconnaître
|
||||
* une ligne dans la liste de la page DSI. Conséquence directe, à assumer dans
|
||||
* l'interface : un jeton n'est affiché QU'UNE FOIS, au moment où il est produit.
|
||||
* Perdu, il ne se retrouve pas — il se régénère, ce qui invalide le précédent.
|
||||
*
|
||||
* Pourquoi SHA-256 sans sel, alors qu'on hache d'ordinaire les secrets avec
|
||||
* password_hash() : un jeton PTA est tiré sur 120 bits d'entropie par le
|
||||
* serveur, il n'est pas devinable par dictionnaire comme le serait un mot de
|
||||
* passe choisi par un humain. Le durcissement lent de password_hash() n'apporte
|
||||
* donc rien ici, alors qu'il interdirait de retrouver le compte en une seule
|
||||
* lecture d'index — il faudrait comparer le jeton présenté à CHAQUE compte.
|
||||
*/
|
||||
final class CompteModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Alphabet des jetons : chiffres et majuscules, MOINS I, O, 0 et 1.
|
||||
* Les jetons sont recopiés à la main depuis un courriel ou un papier : les
|
||||
* caractères qu'on confond à la lecture en sont exclus.
|
||||
*/
|
||||
private const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
|
||||
/** 4 groupes de 6 caractères = 24 tirages sur 32 valeurs, soit 120 bits. */
|
||||
private const GROUPES = 4;
|
||||
private const TAILLE_GROUPE = 6;
|
||||
|
||||
/**
|
||||
* Longueur minimale d'un mot de passe. La longueur prime sur la complexité :
|
||||
* imposer une majuscule et un chiffre produit « Motdepasse1 », pas un secret.
|
||||
*/
|
||||
public const MOT_DE_PASSE_MINIMUM = 12;
|
||||
|
||||
/**
|
||||
* Longueur maximale, imposée par bcrypt : password_hash() ne considère que
|
||||
* les 72 PREMIERS OCTETS et ignore le reste EN SILENCE. Refuser au-delà vaut
|
||||
* mieux que laisser croire qu'une phrase de 90 caractères protège mieux
|
||||
* qu'un préfixe de 72.
|
||||
*/
|
||||
public const MOT_DE_PASSE_MAXIMUM = 72;
|
||||
|
||||
/**
|
||||
* Empreinte du jeton d'installation posé par la migration V7.11.
|
||||
* Sert uniquement à afficher l'alerte « ce jeton est public, régénérez-le »
|
||||
* tant que le compte DSI initial n'a pas été renouvelé.
|
||||
*/
|
||||
public const EMPREINTE_INSTALLATION = '7f61ffc8efed4ed46d581a98286115ad03bb0122ffc70db3398584348aca431d';
|
||||
|
||||
/**
|
||||
* Vérifie un jeton présenté à la connexion.
|
||||
*
|
||||
* La vue ne renvoie le compte que s'il est actif ET que l'identité derrière
|
||||
* lui l'est aussi : un agent désactivé ne peut plus entrer, même avec un
|
||||
* jeton techniquement valide.
|
||||
*
|
||||
* @return array<string, mixed>|null Le compte, ou null si le jeton est
|
||||
* inconnu, révoqué ou porté par une
|
||||
* identité inactive.
|
||||
*/
|
||||
public function authentifier(string $jeton): ?array
|
||||
{
|
||||
$jeton = self::normaliser($jeton);
|
||||
if ($jeton === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT * FROM v_acces_compte
|
||||
WHERE jeton_empreinte = :empreinte AND actif = 1 AND identite_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['empreinte' => self::empreinte($jeton)]);
|
||||
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit un compte par son identifiant, pour la SECONDE étape de connexion.
|
||||
*
|
||||
* Les mêmes conditions qu'à l'authentification par jeton s'appliquent — actif
|
||||
* et identité active : entre la saisie du jeton et celle du mot de passe, la
|
||||
* DSI a pu révoquer le compte. La ligne est renvoyée brute, avec son hachage,
|
||||
* là où trouver() la met en forme pour l'API et le retire.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function trouverPourAuthentification(int $compteId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT * FROM v_acces_compte
|
||||
WHERE id_compte = :id AND actif = 1 AND identite_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/** Horodate une connexion réussie et incrémente son compteur. */
|
||||
public function enregistrerConnexion(int $compteId): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces
|
||||
SET date_derniere_connexion = NOW(),
|
||||
nombre_connexions = nombre_connexions + 1
|
||||
WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des comptes pour la page DSI.
|
||||
*
|
||||
* @param array<string, mixed> $filtres role, actif (0|1), q (texte libre).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function lister(array $filtres = []): array
|
||||
{
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
$role = (string) ($filtres['role'] ?? '');
|
||||
if ($role !== '') {
|
||||
$conditions[] = 'role = :role';
|
||||
$params['role'] = $role;
|
||||
}
|
||||
|
||||
if (isset($filtres['actif']) && $filtres['actif'] !== '') {
|
||||
$conditions[] = 'actif = :actif';
|
||||
$params['actif'] = (int) $filtres['actif'];
|
||||
}
|
||||
|
||||
$recherche = trim((string) ($filtres['q'] ?? ''));
|
||||
if ($recherche !== '') {
|
||||
$conditions[] = '(identite_libelle LIKE :q OR libelle LIKE :q OR matricule LIKE :q OR jeton_prefixe LIKE :q)';
|
||||
$params['q'] = '%' . $recherche . '%';
|
||||
}
|
||||
|
||||
$where = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_compte, role, libelle, identite_libelle, identite_active, id_agent, id_structure,
|
||||
matricule, structure_nom, jeton_prefixe, jeton_empreinte, actif, commentaire,
|
||||
date_creation, date_rotation, date_derniere_connexion, nombre_connexions,
|
||||
mot_de_passe_requis, mot_de_passe_defini, date_mot_de_passe
|
||||
FROM v_acces_compte' . $where . '
|
||||
ORDER BY FIELD(role, \'DSI\', \'SERVICE_ENFANCE\', \'RESPONSABLE_STRUCTURE\', \'AGENT\'), identite_libelle'
|
||||
);
|
||||
$stmt->execute($params);
|
||||
|
||||
return array_map(
|
||||
fn (array $ligne): array => $this->presenter($ligne),
|
||||
$stmt->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un compte par son identifiant.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function trouver(int $compteId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM v_acces_compte WHERE id_compte = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
$compte = $stmt->fetch();
|
||||
|
||||
return $compte === false ? null : $this->presenter($compte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identités actives encore dépourvues de compte.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function manquants(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT role, id_agent, id_structure, libelle FROM v_acces_compte_manquant ORDER BY role, libelle'
|
||||
);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un compte et retourne le jeton EN CLAIR — unique occasion de le lire.
|
||||
*
|
||||
* @return array{compte: array<string, mixed>, jeton: string}
|
||||
*
|
||||
* @throws DomainException Si le rôle, l'identité ou l'unicité ne conviennent pas.
|
||||
*/
|
||||
public function creer(
|
||||
string $role,
|
||||
?int $agentId,
|
||||
?int $structureId,
|
||||
string $libelle,
|
||||
?string $commentaire = null
|
||||
): array {
|
||||
$role = strtoupper(trim($role));
|
||||
$libelle = trim($libelle);
|
||||
|
||||
if (!in_array($role, [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE, Access::ROLE_DSI], true)) {
|
||||
throw new DomainException('Rôle inconnu.');
|
||||
}
|
||||
|
||||
// Un compte AGENT ou RESPONSABLE_STRUCTURE tire son périmètre de son
|
||||
// identité : sans identité, la session n'aurait aucun lieu ni aucun
|
||||
// agent à borner. Les deux autres rôles n'ont pas de périmètre à borner.
|
||||
if ($role === Access::ROLE_AGENT) {
|
||||
$structureId = null;
|
||||
if ($agentId === null) {
|
||||
throw new DomainException('Choisissez l’agent titulaire du jeton.');
|
||||
}
|
||||
$libelle = $this->libelleAgent($agentId);
|
||||
} elseif ($role === Access::ROLE_RESPONSABLE) {
|
||||
$agentId = null;
|
||||
if ($structureId === null) {
|
||||
throw new DomainException('Choisissez le lieu d’affectation titulaire du jeton.');
|
||||
}
|
||||
$libelle = $this->libelleStructure($structureId);
|
||||
} else {
|
||||
$agentId = null;
|
||||
$structureId = null;
|
||||
if ($libelle === '') {
|
||||
throw new DomainException('Indiquez le nom du titulaire du jeton.');
|
||||
}
|
||||
}
|
||||
|
||||
$jeton = self::genererJeton();
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO compte_acces (role, libelle, id_agent, id_structure, jeton_empreinte, jeton_prefixe, commentaire)
|
||||
VALUES (:role, :libelle, :id_agent, :id_structure, :empreinte, :prefixe, :commentaire)'
|
||||
);
|
||||
|
||||
try {
|
||||
$stmt->execute([
|
||||
'role' => $role,
|
||||
'libelle' => mb_substr($libelle, 0, 180),
|
||||
'id_agent' => $agentId,
|
||||
'id_structure' => $structureId,
|
||||
'empreinte' => self::empreinte($jeton),
|
||||
'prefixe' => self::prefixe($jeton),
|
||||
'commentaire' => $commentaire !== null && trim($commentaire) !== ''
|
||||
? mb_substr(trim($commentaire), 0, 255)
|
||||
: null,
|
||||
]);
|
||||
} catch (\PDOException $e) {
|
||||
// 23000 = violation de contrainte : les index uniques sur id_agent et
|
||||
// id_structure garantissent un seul compte par identité.
|
||||
if ($e->getCode() === '23000') {
|
||||
throw new DomainException('Cette identité possède déjà un compte d’accès.');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$compte = $this->trouver((int) $this->pdo->lastInsertId());
|
||||
|
||||
return ['compte' => $compte ?? [], 'jeton' => $jeton];
|
||||
}
|
||||
|
||||
/**
|
||||
* Régénère le jeton d'un compte : l'ancien cesse immédiatement de
|
||||
* fonctionner, y compris pour les sessions ouvertes — Access relit le compte
|
||||
* à chaque requête, mais c'est l'empreinte qui a changé, pas le compte.
|
||||
*
|
||||
* @return string Le nouveau jeton, en clair, à transmettre au titulaire.
|
||||
*
|
||||
* @throws DomainException Si le compte n'existe pas.
|
||||
*/
|
||||
public function regenererJeton(int $compteId): string
|
||||
{
|
||||
if ($this->trouver($compteId) === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
|
||||
$jeton = self::genererJeton();
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces
|
||||
SET jeton_empreinte = :empreinte,
|
||||
jeton_prefixe = :prefixe,
|
||||
date_rotation = NOW()
|
||||
WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'empreinte' => self::empreinte($jeton),
|
||||
'prefixe' => self::prefixe($jeton),
|
||||
'id' => $compteId,
|
||||
]);
|
||||
|
||||
return $jeton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active ou désactive un compte.
|
||||
*
|
||||
* @throws DomainException Si l'opération laisserait l'application sans
|
||||
* aucune DSI en état de se connecter.
|
||||
*/
|
||||
public function definirActif(int $compteId, bool $actif): void
|
||||
{
|
||||
$compte = $this->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
|
||||
if (!$actif) {
|
||||
$this->assurerDerniereDsi($compte);
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('UPDATE compte_acces SET actif = :actif WHERE id_compte = :id');
|
||||
$stmt->execute(['actif' => $actif ? 1 : 0, 'id' => $compteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime définitivement un compte. Les traces qu'il a laissées dans le
|
||||
* journal SURVIVENT : la clé étrangère est en ON DELETE SET NULL et le
|
||||
* libellé de l'acteur y est figé.
|
||||
*
|
||||
* @throws DomainException Si c'est le dernier accès DSI actif.
|
||||
*/
|
||||
public function supprimer(int $compteId): void
|
||||
{
|
||||
$compte = $this->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
|
||||
$this->assurerDerniereDsi($compte);
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM compte_acces WHERE id_compte = :id');
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère en une passe un jeton pour chaque agent et chaque lieu actif qui
|
||||
* n'en a pas. C'est l'outil de MISE EN SERVICE : sans lui, il faudrait créer
|
||||
* les comptes un par un avant que quiconque puisse se connecter.
|
||||
*
|
||||
* @return array<int, array{role: string, libelle: string, jeton: string, id_compte: int}>
|
||||
* Les jetons produits, en clair. Cette liste est la seule occasion
|
||||
* de les lire : elle doit être distribuée puis refermée.
|
||||
*/
|
||||
public function genererManquants(): array
|
||||
{
|
||||
$produits = [];
|
||||
|
||||
foreach ($this->manquants() as $identite) {
|
||||
$role = (string) $identite['role'];
|
||||
$agentId = $identite['id_agent'] !== null ? (int) $identite['id_agent'] : null;
|
||||
$structureId = $identite['id_structure'] !== null ? (int) $identite['id_structure'] : null;
|
||||
|
||||
$resultat = $this->creer($role, $agentId, $structureId, (string) $identite['libelle']);
|
||||
$produits[] = [
|
||||
'id_compte' => (int) ($resultat['compte']['id_compte'] ?? 0),
|
||||
'role' => $role,
|
||||
'libelle' => (string) ($resultat['compte']['identite_libelle'] ?? $identite['libelle']),
|
||||
'jeton' => $resultat['jeton'],
|
||||
];
|
||||
}
|
||||
|
||||
return $produits;
|
||||
}
|
||||
|
||||
/** Nombre de comptes DSI actifs, hors compte $exclu. */
|
||||
public function compterDsiActives(?int $exclu = null): int
|
||||
{
|
||||
// Zéro plutôt que NULL : PDO tourne sans émulation de requêtes préparées,
|
||||
// un même paramètre nommé ne peut donc pas être répété dans la requête,
|
||||
// et aucun compte ne porte l'identifiant 0.
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) FROM v_acces_compte
|
||||
WHERE role = :role AND actif = 1 AND identite_active = 1
|
||||
AND id_compte <> :exclu'
|
||||
);
|
||||
$stmt->execute(['role' => Access::ROLE_DSI, 'exclu' => $exclu ?? 0]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai tant qu'un compte actif porte encore le jeton d'installation, dont
|
||||
* l'empreinte est écrite en clair dans la migration versionnée — donc
|
||||
* publique. L'interface DSI s'en sert pour alerter en rouge.
|
||||
*
|
||||
* La question est posée à la base plutôt qu'à la liste affichée : un filtre
|
||||
* de recherche ne doit pas faire disparaître l'alerte.
|
||||
*/
|
||||
public function installationEnCours(): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT 1 FROM compte_acces
|
||||
WHERE jeton_empreinte = :empreinte AND actif = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['empreinte' => self::EMPREINTE_INSTALLATION]);
|
||||
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce compte réclame-t-il un mot de passe en plus de son jeton ?
|
||||
*
|
||||
* La règle vit dans la vue (`mot_de_passe_requis`), pas ici : la liste des
|
||||
* rôles concernés se modifie en SQL, comme le reste des droits depuis la
|
||||
* V7.8.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte.
|
||||
*/
|
||||
public static function exigeMotDePasse(array $compte): bool
|
||||
{
|
||||
return (int) ($compte['mot_de_passe_requis'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le titulaire a-t-il déjà choisi son mot de passe ?
|
||||
* Faux à la création du compte comme après une réinitialisation par la DSI :
|
||||
* la connexion suivante impose alors d'en définir un.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte.
|
||||
*/
|
||||
public static function motDePasseDefini(array $compte): bool
|
||||
{
|
||||
return (int) ($compte['mot_de_passe_defini'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le mot de passe présenté, et remet l'empreinte à niveau si PHP a
|
||||
* changé d'algorithme par défaut depuis sa création.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte, avec son hachage.
|
||||
*/
|
||||
public function verifierMotDePasse(array $compte, string $motDePasse): bool
|
||||
{
|
||||
$hash = (string) ($compte['mot_de_passe_hash'] ?? '');
|
||||
if ($hash === '' || !password_verify($motDePasse, $hash)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
|
||||
$this->enregistrerHash((int) $compte['id_compte'], password_hash($motDePasse, PASSWORD_DEFAULT));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le mot de passe choisi par le titulaire.
|
||||
*
|
||||
* @throws DomainException Si la politique n'est pas respectée.
|
||||
*/
|
||||
public function definirMotDePasse(int $compteId, string $motDePasse, string $confirmation): void
|
||||
{
|
||||
if ($motDePasse !== $confirmation) {
|
||||
throw new DomainException('Les deux mots de passe saisis ne sont pas identiques.');
|
||||
}
|
||||
|
||||
$this->assurerPolitique($motDePasse);
|
||||
$this->enregistrerHash($compteId, password_hash($motDePasse, PASSWORD_DEFAULT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Efface le mot de passe d'un compte : la DSI ne le remplace pas, elle le
|
||||
* RETIRE. Le titulaire en choisira un nouveau à sa prochaine connexion, avec
|
||||
* son jeton — la DSI ne connaît donc jamais le mot de passe de personne.
|
||||
*
|
||||
* @throws DomainException Si le compte n'existe pas ou n'a pas de mot de passe.
|
||||
*/
|
||||
public function reinitialiserMotDePasse(int $compteId): array
|
||||
{
|
||||
$compte = $this->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
if (!$compte['mot_de_passe_requis']) {
|
||||
throw new DomainException('Ce type de compte n’utilise pas de mot de passe.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces SET mot_de_passe_hash = NULL, date_mot_de_passe = NULL WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
|
||||
return $compte;
|
||||
}
|
||||
|
||||
/**
|
||||
* Politique de mot de passe : la longueur, et rien d'autre.
|
||||
*
|
||||
* @throws DomainException Si le mot de passe est trop court, trop long ou
|
||||
* trop proche du jeton.
|
||||
*/
|
||||
private function assurerPolitique(string $motDePasse): void
|
||||
{
|
||||
// Mesure en OCTETS et non en caractères : c'est la limite de bcrypt, et
|
||||
// un accent compte pour deux.
|
||||
$longueur = strlen($motDePasse);
|
||||
|
||||
if ($longueur < self::MOT_DE_PASSE_MINIMUM) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le mot de passe doit contenir au moins %d caractères.',
|
||||
self::MOT_DE_PASSE_MINIMUM
|
||||
));
|
||||
}
|
||||
if ($longueur > self::MOT_DE_PASSE_MAXIMUM) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le mot de passe ne peut pas dépasser %d caractères.',
|
||||
self::MOT_DE_PASSE_MAXIMUM
|
||||
));
|
||||
}
|
||||
if (str_starts_with(self::normaliser($motDePasse), 'PTA-')) {
|
||||
// Recopier son jeton dans le champ mot de passe ramènerait les deux
|
||||
// facteurs à un seul secret.
|
||||
throw new DomainException('Le mot de passe ne peut pas être votre jeton d’accès.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Écrit une empreinte de mot de passe et horodate le changement. */
|
||||
private function enregistrerHash(int $compteId, string $hash): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces
|
||||
SET mot_de_passe_hash = :hash, date_mot_de_passe = NOW()
|
||||
WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute(['hash' => $hash, 'id' => $compteId]);
|
||||
}
|
||||
|
||||
/** Un jeton en clair, prêt à être transmis : « PTA-XXXXXX-XXXXXX-… ». */
|
||||
public static function genererJeton(): string
|
||||
{
|
||||
$groupes = [];
|
||||
for ($groupe = 0; $groupe < self::GROUPES; $groupe++) {
|
||||
$caracteres = '';
|
||||
for ($index = 0; $index < self::TAILLE_GROUPE; $index++) {
|
||||
// random_int() et non rand() : le tirage doit être imprévisible.
|
||||
$caracteres .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
|
||||
}
|
||||
$groupes[] = $caracteres;
|
||||
}
|
||||
|
||||
return 'PTA-' . implode('-', $groupes);
|
||||
}
|
||||
|
||||
/** Empreinte stockée en base. */
|
||||
public static function empreinte(string $jeton): string
|
||||
{
|
||||
return hash('sha256', self::normaliser($jeton));
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie un jeton saisi à la main : espaces, casse et espaces insécables
|
||||
* collés par un copier-coller depuis un courriel.
|
||||
*/
|
||||
public static function normaliser(string $jeton): string
|
||||
{
|
||||
$jeton = str_replace(["\u{00A0}", ' ', "\t", "\r", "\n"], '', $jeton);
|
||||
|
||||
return strtoupper(trim($jeton));
|
||||
}
|
||||
|
||||
/** Début du jeton, conservé en base pour l'identifier sans le révéler. */
|
||||
private static function prefixe(string $jeton): string
|
||||
{
|
||||
return mb_substr(self::normaliser($jeton), 0, 11);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en forme une ligne de v_acces_compte pour l'API : types corrects,
|
||||
* empreinte remplacée par le seul drapeau qui intéresse l'interface.
|
||||
*
|
||||
* @param array<string, mixed> $ligne
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function presenter(array $ligne): array
|
||||
{
|
||||
return [
|
||||
'id_compte' => (int) $ligne['id_compte'],
|
||||
'role' => (string) $ligne['role'],
|
||||
'libelle' => (string) $ligne['libelle'],
|
||||
'identite_libelle' => (string) ($ligne['identite_libelle'] ?? $ligne['libelle']),
|
||||
'identite_active' => (int) ($ligne['identite_active'] ?? 1) === 1,
|
||||
'id_agent' => $ligne['id_agent'] !== null ? (int) $ligne['id_agent'] : null,
|
||||
'id_structure' => $ligne['id_structure'] !== null ? (int) $ligne['id_structure'] : null,
|
||||
'matricule' => $ligne['matricule'] ?? null,
|
||||
'structure_nom' => $ligne['structure_nom'] ?? null,
|
||||
'jeton_prefixe' => (string) $ligne['jeton_prefixe'],
|
||||
// Le jeton d'installation est public : l'interface doit pouvoir
|
||||
// crier tant qu'il n'a pas été remplacé.
|
||||
'jeton_installation' => (string) $ligne['jeton_empreinte'] === self::EMPREINTE_INSTALLATION,
|
||||
'actif' => (int) $ligne['actif'] === 1,
|
||||
// Le hachage ne sort jamais du serveur : l'interface n'a besoin que
|
||||
// de savoir si un mot de passe est exigé et s'il a été choisi.
|
||||
'mot_de_passe_requis' => (int) ($ligne['mot_de_passe_requis'] ?? 0) === 1,
|
||||
'mot_de_passe_defini' => (int) ($ligne['mot_de_passe_defini'] ?? 0) === 1,
|
||||
'date_mot_de_passe' => $ligne['date_mot_de_passe'] ?? null,
|
||||
'commentaire' => $ligne['commentaire'] ?? null,
|
||||
'date_creation' => $ligne['date_creation'],
|
||||
'date_rotation' => $ligne['date_rotation'],
|
||||
'date_derniere_connexion' => $ligne['date_derniere_connexion'],
|
||||
'nombre_connexions' => (int) $ligne['nombre_connexions'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interdit de se couper soi-même la branche : le dernier accès DSI actif ne
|
||||
* peut être ni désactivé ni supprimé. Sans ce garde-fou, il faudrait rejouer
|
||||
* la migration pour récupérer la main.
|
||||
*
|
||||
* @param array<string, mixed> $compte
|
||||
*/
|
||||
private function assurerDerniereDsi(array $compte): void
|
||||
{
|
||||
if ((string) $compte['role'] !== Access::ROLE_DSI || !$compte['actif']) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->compterDsiActives((int) $compte['id_compte']) === 0) {
|
||||
throw new DomainException(
|
||||
'Ce compte est le dernier accès DSI actif : créez-en un autre avant de le retirer.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Libellé d'un agent actif, ou refus si l'agent n'existe pas ou est inactif. */
|
||||
private function libelleAgent(int $agentId): string
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT CONCAT(prenom, \' \', nom) FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $agentId]);
|
||||
$libelle = $stmt->fetchColumn();
|
||||
|
||||
if ($libelle === false) {
|
||||
throw new DomainException('Agent introuvable ou inactif.');
|
||||
}
|
||||
|
||||
return (string) $libelle;
|
||||
}
|
||||
|
||||
/** Libellé d'un lieu actif, ou refus si le lieu n'existe pas ou est fermé. */
|
||||
private function libelleStructure(int $structureId): string
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT nom FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $structureId]);
|
||||
$libelle = $stmt->fetchColumn();
|
||||
|
||||
if ($libelle === false) {
|
||||
throw new DomainException('Lieu d’affectation introuvable ou inactif.');
|
||||
}
|
||||
|
||||
return (string) $libelle;
|
||||
}
|
||||
}
|
||||
265
app/Models/JournalModel.php
Normal file
265
app/Models/JournalModel.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Lecture du journal d'activité pour la page DSI (V7.11).
|
||||
*
|
||||
* Ce modèle ne fait que LIRE — l'écriture appartient à App\Core\Journal, qui
|
||||
* n'est jamais loin d'une action métier. La seule exception est la purge, une
|
||||
* opération d'exploitation déclenchée à la main par la DSI.
|
||||
*
|
||||
* Le volume est la contrainte de conception : un journal se consulte par
|
||||
* tranches. Toutes les recherches sont donc paginées et bornées, et le compte
|
||||
* total est demandé séparément plutôt que ramené ligne par ligne.
|
||||
*/
|
||||
final class JournalModel extends BaseModel
|
||||
{
|
||||
/** Taille de page par défaut, alignée sur ce qu'un écran affiche sans lassitude. */
|
||||
public const TAILLE_PAGE = 50;
|
||||
|
||||
/** Plafond dur : aucune requête ne peut ramener davantage de lignes. */
|
||||
private const TAILLE_PAGE_MAX = 200;
|
||||
|
||||
/**
|
||||
* Recherche paginée dans le journal.
|
||||
*
|
||||
* @param array<string, mixed> $filtres categorie, resultat, role, id_compte,
|
||||
* q (texte libre), du, au (AAAA-MM-JJ),
|
||||
* page, taille.
|
||||
*
|
||||
* @return array{entrees: array<int, array<string, mixed>>, total: int, page: int, taille: int, pages: int}
|
||||
*/
|
||||
public function rechercher(array $filtres = []): array
|
||||
{
|
||||
[$where, $params] = $this->conditions($filtres);
|
||||
|
||||
$taille = (int) ($filtres['taille'] ?? self::TAILLE_PAGE);
|
||||
$taille = max(10, min(self::TAILLE_PAGE_MAX, $taille));
|
||||
$page = max(1, (int) ($filtres['page'] ?? 1));
|
||||
|
||||
$total = $this->compter($where, $params);
|
||||
$pages = $total === 0 ? 1 : (int) ceil($total / $taille);
|
||||
// Une page vidée par un filtre plus restrictif ne doit pas renvoyer un
|
||||
// écran blanc : on se replie sur la dernière page réellement peuplée.
|
||||
$page = min($page, $pages);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_journal, date_evenement, categorie, action, resultat, id_compte, role,
|
||||
acteur, cible_table, cible_id, description, contexte, adresse_ip,
|
||||
agent_utilisateur, jeton_prefixe
|
||||
FROM v_journal_activite' . $where . '
|
||||
ORDER BY date_evenement DESC, id_journal DESC
|
||||
LIMIT :limite OFFSET :decalage'
|
||||
);
|
||||
|
||||
foreach ($params as $nom => $valeur) {
|
||||
$stmt->bindValue(':' . $nom, $valeur);
|
||||
}
|
||||
// LIMIT et OFFSET doivent être liés en ENTIER : sans émulation de
|
||||
// requêtes préparées, une valeur liée en chaîne produirait un LIMIT '50'
|
||||
// que MariaDB refuse.
|
||||
$stmt->bindValue(':limite', $taille, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':decalage', ($page - 1) * $taille, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return [
|
||||
'entrees' => array_map([$this, 'presenter'], $stmt->fetchAll()),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'taille' => $taille,
|
||||
'pages' => $pages,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Quelques chiffres de tête pour le bandeau de la page DSI.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function statistiques(): array
|
||||
{
|
||||
$total = (int) $this->pdo->query('SELECT COUNT(*) FROM journal_activite')->fetchColumn();
|
||||
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT categorie, COUNT(*) AS nombre
|
||||
FROM journal_activite
|
||||
WHERE date_evenement >= NOW() - INTERVAL 7 DAY
|
||||
GROUP BY categorie'
|
||||
);
|
||||
$parCategorie = [];
|
||||
foreach ($stmt->fetchAll() as $ligne) {
|
||||
$parCategorie[(string) $ligne['categorie']] = (int) $ligne['nombre'];
|
||||
}
|
||||
|
||||
$refus24h = (int) $this->pdo->query(
|
||||
'SELECT COUNT(*) FROM journal_activite
|
||||
WHERE resultat = \'REFUS\' AND date_evenement >= NOW() - INTERVAL 1 DAY'
|
||||
)->fetchColumn();
|
||||
|
||||
$connexions24h = (int) $this->pdo->query(
|
||||
'SELECT COUNT(*) FROM journal_activite
|
||||
WHERE categorie = \'CONNEXION\' AND resultat = \'SUCCES\'
|
||||
AND date_evenement >= NOW() - INTERVAL 1 DAY'
|
||||
)->fetchColumn();
|
||||
|
||||
$plusAncienne = $this->pdo->query(
|
||||
'SELECT MIN(date_evenement) FROM journal_activite'
|
||||
)->fetchColumn();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'sept_jours' => $parCategorie,
|
||||
'refus_24h' => $refus24h,
|
||||
'connexions_24h' => $connexions24h,
|
||||
'plus_ancienne' => $plusAncienne ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de secrets refusés pour une adresse IP sur les dernières minutes.
|
||||
*
|
||||
* C'est la mesure qui alimente le frein anti-force-brute de la page de
|
||||
* connexion : le journal sert ici de compteur, sans table supplémentaire.
|
||||
* Les deux facteurs comptent dans le même compteur — jeton refusé (V7.11) et
|
||||
* mot de passe refusé (V7.12) : essayer mille mots de passe derrière un jeton
|
||||
* volé doit buter sur le même mur qu'essayer mille jetons.
|
||||
*/
|
||||
public function echecsRecents(string $adresseIp, int $minutes): int
|
||||
{
|
||||
if ($adresseIp === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) FROM journal_activite
|
||||
WHERE categorie = \'CONNEXION\'
|
||||
AND action IN (\'JETON_REFUSE\', \'MOT_DE_PASSE_REFUSE\')
|
||||
AND adresse_ip = :ip
|
||||
AND date_evenement >= NOW() - INTERVAL :minutes MINUTE'
|
||||
);
|
||||
$stmt->bindValue(':ip', $adresseIp);
|
||||
$stmt->bindValue(':minutes', $minutes, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge les entrées antérieures à $jours jours.
|
||||
*
|
||||
* Le plancher de 30 jours est volontaire : une purge trop courte reviendrait
|
||||
* à effacer les traces d'un incident avant même qu'il soit constaté.
|
||||
*
|
||||
* @return int Nombre de lignes supprimées.
|
||||
*/
|
||||
public function purger(int $jours): int
|
||||
{
|
||||
$jours = max(30, $jours);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM journal_activite WHERE date_evenement < NOW() - INTERVAL :jours DAY'
|
||||
);
|
||||
$stmt->bindValue(':jours', $jours, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la clause WHERE commune à la recherche et au comptage.
|
||||
*
|
||||
* @param array<string, mixed> $filtres
|
||||
*
|
||||
* @return array{0: string, 1: array<string, mixed>}
|
||||
*/
|
||||
private function conditions(array $filtres): array
|
||||
{
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
foreach (['categorie' => 'categorie', 'resultat' => 'resultat', 'role' => 'role'] as $filtre => $colonne) {
|
||||
$valeur = trim((string) ($filtres[$filtre] ?? ''));
|
||||
if ($valeur !== '') {
|
||||
$conditions[] = $colonne . ' = :' . $filtre;
|
||||
$params[$filtre] = $valeur;
|
||||
}
|
||||
}
|
||||
|
||||
$compteId = (int) ($filtres['id_compte'] ?? 0);
|
||||
if ($compteId > 0) {
|
||||
$conditions[] = 'id_compte = :id_compte';
|
||||
$params['id_compte'] = $compteId;
|
||||
}
|
||||
|
||||
$du = trim((string) ($filtres['du'] ?? ''));
|
||||
if ($du !== '') {
|
||||
$conditions[] = 'date_evenement >= :du';
|
||||
$params['du'] = $du . ' 00:00:00';
|
||||
}
|
||||
|
||||
$au = trim((string) ($filtres['au'] ?? ''));
|
||||
if ($au !== '') {
|
||||
$conditions[] = 'date_evenement <= :au';
|
||||
$params['au'] = $au . ' 23:59:59';
|
||||
}
|
||||
|
||||
$recherche = trim((string) ($filtres['q'] ?? ''));
|
||||
if ($recherche !== '') {
|
||||
$conditions[] = '(acteur LIKE :q OR action LIKE :q OR description LIKE :q OR adresse_ip LIKE :q)';
|
||||
$params['q'] = '%' . $recherche . '%';
|
||||
}
|
||||
|
||||
return [$conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions), $params];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function compter(string $where, array $params): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM v_journal_activite' . $where);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en forme une entrée pour l'API. `contexte` est décodé ici : le client
|
||||
* reçoit un objet, pas une chaîne JSON à réanalyser.
|
||||
*
|
||||
* @param array<string, mixed> $ligne
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function presenter(array $ligne): array
|
||||
{
|
||||
$contexte = null;
|
||||
if (!empty($ligne['contexte'])) {
|
||||
$decode = json_decode((string) $ligne['contexte'], true);
|
||||
$contexte = is_array($decode) ? $decode : null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id_journal' => (int) $ligne['id_journal'],
|
||||
'date_evenement' => (string) $ligne['date_evenement'],
|
||||
'categorie' => (string) $ligne['categorie'],
|
||||
'action' => (string) $ligne['action'],
|
||||
'resultat' => (string) $ligne['resultat'],
|
||||
'id_compte' => $ligne['id_compte'] !== null ? (int) $ligne['id_compte'] : null,
|
||||
'role' => $ligne['role'],
|
||||
'acteur' => $ligne['acteur'],
|
||||
'cible_table' => $ligne['cible_table'],
|
||||
'cible_id' => $ligne['cible_id'] !== null ? (int) $ligne['cible_id'] : null,
|
||||
'description' => $ligne['description'],
|
||||
'contexte' => $contexte,
|
||||
'adresse_ip' => $ligne['adresse_ip'],
|
||||
'agent_utilisateur' => $ligne['agent_utilisateur'],
|
||||
'jeton_prefixe' => $ligne['jeton_prefixe'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -437,6 +437,34 @@ final class PlanningModel extends BaseModel
|
||||
*
|
||||
* @return array<int, array{c1: int, c2: int, date_jour: string}>
|
||||
*/
|
||||
/**
|
||||
* Créneaux du planning tombant hors des dates de contrat de leur agent.
|
||||
*
|
||||
* Contrôle de VALIDATION, complémentaire des gardes posées à l'écriture
|
||||
* (V7.15) : celles-ci empêchent d'en créer de nouveaux, celui-ci révèle ceux
|
||||
* qui existaient déjà — saisis avant la règle, ou rendus illégitimes par un
|
||||
* contrat raccourci depuis. La comparaison est faite en SQL, sur la fiche
|
||||
* agent jointe : c'est elle qui fait foi au moment du contrôle.
|
||||
*
|
||||
* @return array<int, array{id_creneau: int, date_jour: string}>
|
||||
*/
|
||||
public function entriesOutsideContract(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT c.id_creneau, c.date_jour
|
||||
FROM creneau_horaire c
|
||||
JOIN planning p ON p.id_planning = c.id_planning
|
||||
JOIN agent a ON a.id_agent = p.id_agent
|
||||
WHERE c.id_planning = :id_planning
|
||||
AND ((a.date_debut_contrat IS NOT NULL AND c.date_jour < a.date_debut_contrat)
|
||||
OR (a.date_fin_contrat IS NOT NULL AND c.date_jour > a.date_fin_contrat))
|
||||
ORDER BY c.date_jour'
|
||||
);
|
||||
$stmt->execute(['id_planning' => $planningId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function internalOverlaps(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
@@ -56,6 +56,8 @@ final class SchemaModel extends BaseModel
|
||||
'equipe_pta',
|
||||
'equipe_pta_membre',
|
||||
'structure_creneau_reference',
|
||||
'compte_acces',
|
||||
'journal_activite',
|
||||
];
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
|
||||
@@ -82,6 +84,7 @@ final class SchemaModel extends BaseModel
|
||||
'planning' => ['date_validation'],
|
||||
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'droit_enveloppe_formation', 'commentaire_pta'],
|
||||
'structure' => ['type_affectation'],
|
||||
'compte_acces' => ['mot_de_passe_hash'],
|
||||
];
|
||||
|
||||
foreach ($requiredColumns as $table => $columns) {
|
||||
@@ -108,6 +111,7 @@ final class SchemaModel extends BaseModel
|
||||
}
|
||||
|
||||
$this->assertAccessViews();
|
||||
$this->assertJetonViews();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,4 +149,39 @@ final class SchemaModel extends BaseModel
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vues des comptes à jeton et du journal (V7.11).
|
||||
*
|
||||
* Même logique fail-closed que pour les vues de droits : sans elles,
|
||||
* personne ne peut plus s'authentifier et aucune action ne serait tracée.
|
||||
* Mieux vaut un message explicite au démarrage qu'une application ouverte
|
||||
* sans journal.
|
||||
*/
|
||||
private function assertJetonViews(): void
|
||||
{
|
||||
$requiredViews = [
|
||||
'v_acces_compte',
|
||||
'v_acces_compte_manquant',
|
||||
'v_journal_activite',
|
||||
];
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($requiredViews), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT TABLE_NAME
|
||||
FROM information_schema.VIEWS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($requiredViews);
|
||||
$missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN)));
|
||||
|
||||
if ($missing) {
|
||||
throw new \RuntimeException(
|
||||
'Comptes d’accès indisponibles : vue(s) manquante(s) : '
|
||||
. implode(', ', $missing)
|
||||
. '. Exécutez database/migration_v7_9e_dsi_comptes_journal.sql.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
|
||||
'params' => $pageConfig,
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
|
||||
</script>
|
||||
<script src="assets/js/core.js?v=7.16"></script>
|
||||
<script src="assets/js/core.js?v=7.23"></script>
|
||||
<?php foreach ($scripts as $script): ?>
|
||||
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.16"></script>
|
||||
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.23"></script>
|
||||
<?php endforeach; ?>
|
||||
<script src="assets/js/page-bootstrap.js?v=7.16"></script>
|
||||
<script src="assets/js/page-bootstrap.js?v=7.23"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$isAgent = ($accessProfile['role'] ?? '') === 'AGENT';
|
||||
// Service Enfance et DSI ouvrent leur session avec un mot de passe (V7.12) :
|
||||
// eux seuls ont quelque chose à changer. La liste des rôles concernés vit en
|
||||
// base (v_acces_compte.mot_de_passe_requis) ; elle est recopiée ici pour un
|
||||
// simple affichage, jamais pour décider d'un droit.
|
||||
$peutChangerMotDePasse = in_array($accessProfile['role'] ?? '', ['SERVICE_ENFANCE', 'DSI'], true);
|
||||
|
||||
$navigation = [
|
||||
'dashboard' => ['dashboard.php', 'Tableau de bord'],
|
||||
@@ -36,7 +41,10 @@ $navigation = [
|
||||
'pta' => ['pta.php', 'PTA détaillé'],
|
||||
'teams' => ['teams.php', 'Équipes'],
|
||||
'vacations' => ['vacations.php', 'Vacances'],
|
||||
'manage_agents' => ['manage_agents.php', 'Agents'],
|
||||
'manage_structures' => ['manage_structures.php', 'Lieux d’affectation'],
|
||||
'administration' => ['administration.php', 'Administration'],
|
||||
'dsi' => ['dsi.php', 'Accès & journaux'],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -49,7 +57,11 @@ $navigationGroups = [
|
||||
'Piloter' => ['dashboard', 'pending', 'coverage'],
|
||||
'Planifier' => ['planning', 'teams'],
|
||||
'Consulter' => ['agents', 'structures', 'annual', 'pta'],
|
||||
'Paramétrer' => ['vacations', 'administration'],
|
||||
// « Management » regroupe l'administration des personnes et des lieux : ce
|
||||
// qu'on crée et corrige au fil de l'eau, à distinguer des paramètres qu'on
|
||||
// règle une fois pour l'année.
|
||||
'Management' => ['manage_agents', 'manage_structures'],
|
||||
'Paramétrer' => ['vacations', 'administration', 'dsi'],
|
||||
];
|
||||
|
||||
$visibleGroups = [];
|
||||
@@ -92,7 +104,7 @@ $showHelpTab = in_array('faq', $allowedPages, true);
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.16">
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.23">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
@@ -113,12 +125,15 @@ $showHelpTab = in_array('faq', $allowedPages, true);
|
||||
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<a class="button button-ghost switch-role-link" href="role.php?action=reset&csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Changer de rôle</a>
|
||||
<?php if ($peutChangerMotDePasse): ?>
|
||||
<a class="button button-ghost switch-role-link" href="role.php?action=mot_de_passe">Mot de passe</a>
|
||||
<?php endif; ?>
|
||||
<a class="button button-ghost switch-role-link" href="role.php?action=reset&csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Se déconnecter</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<?php if (!empty($_GET['access_denied'])): ?>
|
||||
<div class="message message-warning role-access-warning">Cette page n’est pas disponible avec votre rôle actuel.</div>
|
||||
<div class="message message-warning role-access-warning">Cette page n'est pas disponible avec votre rôle actuel.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<nav class="view-tabs<?= $showGroupLabels ? ' view-tabs-grouped' : '' ?>" aria-label="Navigation principale">
|
||||
|
||||
9
app/Views/pages/dsi.php
Normal file
9
app/Views/pages/dsi.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
|
||||
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
|
||||
* les a extraites dans la portée de ce fichier.
|
||||
*/
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/dsi.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
9
app/Views/pages/manage_agents.php
Normal file
9
app/Views/pages/manage_agents.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
|
||||
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
|
||||
* les a extraites dans la portée de ce fichier.
|
||||
*/
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/manage_agents.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
9
app/Views/pages/manage_structures.php
Normal file
9
app/Views/pages/manage_structures.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
|
||||
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
|
||||
* les a extraites dans la portée de ce fichier.
|
||||
*/
|
||||
require dirname(__DIR__) . '/layouts/header.php';
|
||||
require dirname(__DIR__) . '/partials/manage_structures.php';
|
||||
require dirname(__DIR__) . '/layouts/footer.php';
|
||||
@@ -1,131 +1,202 @@
|
||||
<?php
|
||||
/**
|
||||
* Sélecteur de rôle — SEULE vue à ne pas passer par layouts/header.php.
|
||||
* Écran de connexion — SEULE vue à ne pas passer par layouts/header.php.
|
||||
*
|
||||
* Elle s'affiche avant qu'un rôle soit choisi, donc avant tout contrôle
|
||||
* d'accès, et ne peut pas afficher la barre de navigation qui en dépend. Elle
|
||||
* porte donc son propre <head> : c'est le TROISIÈME endroit où incrémenter le
|
||||
* `?v=` des assets, avec layouts/header.php et layouts/footer.php.
|
||||
* Elle s'affiche avant qu'une session existe, donc avant tout contrôle d'accès,
|
||||
* et ne peut pas afficher la barre de navigation qui en dépend. Elle porte donc
|
||||
* son propre <head> : c'est le TROISIÈME endroit où incrémenter le `?v=` des
|
||||
* assets, avec layouts/header.php et layouts/footer.php.
|
||||
*
|
||||
* Rappel : sélection n'est pas authentification. L'utilisateur choisit
|
||||
* librement son identité (voir App\Core\Access::select()).
|
||||
* Depuis la V7.11, ce n'est plus un sélecteur de rôle mais une AUTHENTIFICATION :
|
||||
* l'utilisateur présente le jeton que la DSI lui a remis, et c'est son compte qui
|
||||
* détermine son rôle et son périmètre.
|
||||
*
|
||||
* La V7.12 ajoute un second facteur pour les comptes Service Enfance et DSI.
|
||||
* D'où les quatre états de cet écran, portés par $etape :
|
||||
* « jeton » — saisie du jeton ;
|
||||
* « 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.
|
||||
* Un seul formulaire est affiché à la fois : les étapes sont exclusives.
|
||||
*/
|
||||
/** @var string $csrfToken */
|
||||
/** @var array $agents */
|
||||
/** @var array $structures */
|
||||
/** @var string $error */
|
||||
/** @var string $notice */
|
||||
/** @var string $etape */
|
||||
/** @var string $titulaire */
|
||||
/** @var int $longueurMinimale */
|
||||
/** @var array|null $currentProfile */
|
||||
/** @var string $homeUrl */
|
||||
/** @var string $cspNonce */
|
||||
|
||||
$titres = [
|
||||
'jeton' => 'Connexion',
|
||||
'definition' => 'Choisissez votre mot de passe',
|
||||
'verification' => 'Mot de passe',
|
||||
'changement' => 'Changer de mot de passe',
|
||||
];
|
||||
$titre = $titres[$etape] ?? 'Connexion';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Choisir votre accès — PTA</title>
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.16">
|
||||
<title><?= htmlspecialchars($titre) ?> — PTA</title>
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.23">
|
||||
</head>
|
||||
<body class="role-page-body">
|
||||
<main class="role-page-shell">
|
||||
<section class="role-intro">
|
||||
<p class="eyebrow">PTA</p>
|
||||
<h1>Quel est votre rôle ?</h1>
|
||||
<p>Votre choix détermine les pages visibles et les actions autorisées pendant cette session.</p>
|
||||
<?php if ($currentProfile): ?>
|
||||
<h1><?= htmlspecialchars($titre) ?></h1>
|
||||
|
||||
<?php if ($etape === 'jeton'): ?>
|
||||
<p>Saisissez le jeton d’accès qui vous a été remis. Il détermine votre rôle, les pages visibles et les actions autorisées.</p>
|
||||
<?php elseif ($etape === 'definition'): ?>
|
||||
<p>
|
||||
Votre compte <strong><?= htmlspecialchars($titulaire) ?></strong> engage l’application :
|
||||
il demande un second secret, connu de vous seul. Choisissez-le maintenant, il vous sera demandé à chaque connexion.
|
||||
</p>
|
||||
<?php elseif ($etape === 'verification'): ?>
|
||||
<p>Jeton reconnu. Saisissez le mot de passe de <strong><?= htmlspecialchars($titulaire) ?></strong> pour ouvrir la session.</p>
|
||||
<?php else: ?>
|
||||
<p>Votre mot de passe actuel vous est demandé : sans lui, un poste laissé ouvert suffirait à confisquer votre compte.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($currentProfile && $etape === 'jeton'): ?>
|
||||
<div class="role-current-profile">
|
||||
Profil actuel : <strong><?= htmlspecialchars((string) $currentProfile['role_label']) ?></strong>
|
||||
Session ouverte : <strong><?= htmlspecialchars((string) $currentProfile['role_label']) ?></strong>
|
||||
— <?= htmlspecialchars((string) $currentProfile['label']) ?>
|
||||
<a class="button button-primary role-resume-link" href="<?= htmlspecialchars($homeUrl) ?>">Reprendre</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($notice !== ''): ?>
|
||||
<div class="message message-success"><?= htmlspecialchars($notice) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($error !== ''): ?>
|
||||
<div class="message message-error"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<form method="post" class="role-selection-form" id="role-selection-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||
|
||||
<div class="role-choice-grid">
|
||||
<label class="role-choice-card">
|
||||
<input type="radio" name="role" value="AGENT" required>
|
||||
<span class="role-choice-content">
|
||||
<strong>Agent</strong>
|
||||
<small>Lecture de mon planning et du planning de mon lieu de rattachement.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="role-choice-card">
|
||||
<input type="radio" name="role" value="RESPONSABLE_STRUCTURE" required>
|
||||
<span class="role-choice-content">
|
||||
<strong>Responsable de structure</strong>
|
||||
<small>Saisie et modification des horaires et motifs de mon lieu, uniquement en brouillon.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="role-choice-card">
|
||||
<input type="radio" name="role" value="SERVICE_ENFANCE" required>
|
||||
<span class="role-choice-content">
|
||||
<strong>Service Enfance</strong>
|
||||
<small>Contrôle complet, prévisionnel N+1, administration et validation des plannings.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="agent-context" class="role-context-card" hidden>
|
||||
<?php if ($etape === 'jeton'): ?>
|
||||
<form method="post" class="role-selection-form role-token-form" id="role-token-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||
<label class="field">
|
||||
<span>Je suis l’agent</span>
|
||||
<select name="agent_id" id="role-agent-select">
|
||||
<option value="">Choisir mon identité</option>
|
||||
<?php foreach ($agents as $agent): ?>
|
||||
<option value="<?= (int) $agent['id_agent'] ?>">
|
||||
<?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom'] . ' (' . $agent['matricule'] . ')') ?>
|
||||
<?= !empty($agent['structure_nom']) ? ' — ' . htmlspecialchars((string) $agent['structure_nom']) : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<span>Jeton d’accès</span>
|
||||
<?php /* autocomplete="off" : un jeton n'a pas à être retenu par le navigateur d'un poste partagé. */ ?>
|
||||
<input
|
||||
type="text"
|
||||
name="token"
|
||||
id="role-token-input"
|
||||
class="role-token-input"
|
||||
placeholder="PTA-XXXXXX-XXXXXX-XXXXXX-XXXXXX"
|
||||
autocomplete="off"
|
||||
autocapitalize="characters"
|
||||
spellcheck="false"
|
||||
required
|
||||
autofocus>
|
||||
</label>
|
||||
</div>
|
||||
<div class="role-form-actions">
|
||||
<button type="submit" class="button button-primary">Continuer</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php elseif ($etape === 'definition' || $etape === 'verification'): ?>
|
||||
<form method="post" class="role-selection-form role-token-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||
<input type="hidden" name="etape" value="mot_de_passe">
|
||||
|
||||
<div id="responsable-context" class="role-context-card" hidden>
|
||||
<label class="field">
|
||||
<span>Je suis responsable du lieu</span>
|
||||
<select name="structure_id" id="role-structure-select">
|
||||
<option value="">Choisir le lieu d’affectation</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>">
|
||||
<?= htmlspecialchars((string) $structure['nom']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<span><?= $etape === 'definition' ? 'Nouveau mot de passe' : 'Mot de passe' ?></span>
|
||||
<input
|
||||
type="password"
|
||||
name="mot_de_passe"
|
||||
autocomplete="<?= $etape === 'definition' ? 'new-password' : 'current-password' ?>"
|
||||
minlength="<?= (int) $longueurMinimale ?>"
|
||||
required
|
||||
autofocus>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="role-form-actions">
|
||||
<button type="submit" class="button button-primary">Accéder à PTA</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php if ($etape === 'definition'): ?>
|
||||
<label class="field">
|
||||
<span>Confirmation</span>
|
||||
<input type="password" name="mot_de_passe_confirmation" autocomplete="new-password" minlength="<?= (int) $longueurMinimale ?>" required>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Au moins <?= (int) $longueurMinimale ?> caractères. Aucune règle de composition :
|
||||
une phrase dont vous vous souvenez protège mieux qu’un mot court hérissé de symboles.
|
||||
Il ne sera affiché nulle part — la DSI elle-même ne peut que l’effacer, jamais le lire.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="role-form-actions">
|
||||
<button type="submit" class="button button-primary"><?= $etape === 'definition' ? 'Enregistrer et se connecter' : 'Se connecter' ?></button>
|
||||
</div>
|
||||
<p class="hint role-step-back">
|
||||
<a href="role.php?action=reset&csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Recommencer avec un autre jeton</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<?php else: ?>
|
||||
<form method="post" class="role-selection-form role-token-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||
<input type="hidden" name="etape" value="changement">
|
||||
|
||||
<label class="field">
|
||||
<span>Mot de passe actuel</span>
|
||||
<input type="password" name="mot_de_passe_actuel" autocomplete="current-password" required autofocus>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nouveau mot de passe</span>
|
||||
<input type="password" name="mot_de_passe" autocomplete="new-password" minlength="<?= (int) $longueurMinimale ?>" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Confirmation</span>
|
||||
<input type="password" name="mot_de_passe_confirmation" autocomplete="new-password" minlength="<?= (int) $longueurMinimale ?>" required>
|
||||
</label>
|
||||
<p class="hint">Au moins <?= (int) $longueurMinimale ?> caractères.</p>
|
||||
|
||||
<div class="role-form-actions">
|
||||
<button type="submit" class="button button-primary">Changer le mot de passe</button>
|
||||
</div>
|
||||
<p class="hint role-step-back">
|
||||
<a href="<?= htmlspecialchars($homeUrl) ?>">Revenir à l’application</a>
|
||||
</p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<aside class="role-security-note">
|
||||
<strong>Version de démonstration :</strong> ce sélecteur organise les droits dans l’application, mais ne remplace pas une authentification nominative. En production, le rôle devra être associé au compte connecté.
|
||||
<?php if ($etape === 'changement'): ?>
|
||||
<strong>Un mot de passe oublié ne se retrouve pas :</strong> la DSI l’efface, et vous en choisissez un nouveau à votre connexion suivante, avec votre jeton.
|
||||
<?php else: ?>
|
||||
<strong>Jeton perdu ou compromis ?</strong> Contactez la DSI : elle en génère un nouveau, ce qui révoque immédiatement l’ancien.
|
||||
Un jeton vaut pour une personne — il ne se prête pas, puisque toutes les actions sont journalisées à son nom.
|
||||
Les comptes Service Enfance et DSI y ajoutent un mot de passe, que vous seul connaissez.
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
</main>
|
||||
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
|
||||
(() => {
|
||||
const agentContext = document.querySelector('#agent-context');
|
||||
const responsableContext = document.querySelector('#responsable-context');
|
||||
const agentSelect = document.querySelector('#role-agent-select');
|
||||
const structureSelect = document.querySelector('#role-structure-select');
|
||||
const input = document.querySelector('#role-token-input');
|
||||
if (!input) return;
|
||||
|
||||
function refresh() {
|
||||
const role = document.querySelector('input[name="role"]:checked')?.value || '';
|
||||
agentContext.hidden = role !== 'AGENT';
|
||||
responsableContext.hidden = role !== 'RESPONSABLE_STRUCTURE';
|
||||
agentSelect.required = role === 'AGENT';
|
||||
structureSelect.required = role === 'RESPONSABLE_STRUCTURE';
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="role"]').forEach((input) => input.addEventListener('change', refresh));
|
||||
refresh();
|
||||
/**
|
||||
* Met en forme la saisie au fil de la frappe : majuscules et suppression des
|
||||
* espaces. Les jetons sont recopiés depuis un courriel ou un papier, et un
|
||||
* copier-coller ramène presque toujours une espace ou un retour à la ligne.
|
||||
* Le serveur normalise de toute façon (CompteModel::normaliser) ; ceci n'est
|
||||
* qu'un confort de saisie — et cela ne concerne QUE le jeton, jamais le mot
|
||||
* de passe, dont chaque caractère compte tel qu'il est frappé.
|
||||
*/
|
||||
input.addEventListener('input', () => {
|
||||
const position = input.selectionStart;
|
||||
const nettoye = input.value.toUpperCase().replace(/\s+/g, '');
|
||||
if (nettoye !== input.value) {
|
||||
input.value = nettoye;
|
||||
input.setSelectionRange(position, position);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1,192 +1,19 @@
|
||||
<?php
|
||||
/**
|
||||
* Référentiels : agents, lieux d'affectation, postes, horaires de couverture.
|
||||
* Modules JS : `administration` et `coverage`.
|
||||
* Administration : horaires de couverture attendus des lieux.
|
||||
* Module JS : `coverage`.
|
||||
*
|
||||
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
|
||||
* posés ici sont les points d'ancrage du module JS : les renommer casse le
|
||||
* module sans aucune erreur visible.
|
||||
*
|
||||
* V7.14 : les agents et les lieux d'affectation ont quitté cet écran pour la
|
||||
* page « Management ». Ne restent ici que les règles de couverture, qui ne
|
||||
* décrivent ni une personne ni un lieu mais une EXIGENCE DE PRÉSENCE — le
|
||||
* paramètre que contrôle l'onglet « Couverture ».
|
||||
*/
|
||||
?>
|
||||
<section id="assignment-view" class="app-view page-content">
|
||||
<div class="admin-grid">
|
||||
<section class="card admin-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Ajouter un lieu d’affectation</h2>
|
||||
<p class="section-subtitle">Chaque lieu possède son propre type : périscolaire ou extrascolaire.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="create-structure-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Code <strong>*</strong></span>
|
||||
<input id="new-structure-code" name="code" type="text" maxlength="50" placeholder="Ex. CRECHE_MATUSALEME" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nom <strong>*</strong></span>
|
||||
<input id="new-structure-name" name="nom" type="text" maxlength="150" placeholder="Ex. Crèche Matusalème" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Type d’affectation <strong>*</strong></span>
|
||||
<select id="new-structure-type" name="type_affectation" required>
|
||||
<option value="PERISCOLAIRE">Périscolaire</option>
|
||||
<option value="EXTRASCOLAIRE">Extrascolaire</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Adresse</span>
|
||||
<input id="new-structure-address" name="adresse" type="text" maxlength="255" placeholder="Adresse du lieu">
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<div id="create-structure-message" class="message" role="status" aria-live="polite"></div>
|
||||
<button class="button button-primary" type="submit">Ajouter le lieu</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card admin-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Ajouter un agent</h2>
|
||||
<p class="section-subtitle">L’agent reçoit un lieu principal à sa création, mais peut être intégré aussi bien aux équipes périscolaires qu’extrascolaires et travailler ponctuellement sur d’autres lieux.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="create-agent-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Matricule <strong>*</strong></span>
|
||||
<input id="new-agent-matricule" name="matricule" type="text" maxlength="50" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Poste PTA <strong>*</strong></span>
|
||||
<select id="new-agent-post" name="id_poste" required>
|
||||
<option value="">Choisir un poste</option>
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Type de contrat <strong>*</strong></span>
|
||||
<select id="new-agent-contract-type" required>
|
||||
<option value="PERMANENT">Permanent — soumis au PTA</option>
|
||||
<option value="TEMPORAIRE">Temporaire — hors périmètre PTA</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Lieu d’affectation principal <strong>*</strong></span>
|
||||
<select id="new-agent-structure" name="structure_id" required>
|
||||
<option value="">Choisir un lieu d’affectation</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>">
|
||||
<?= htmlspecialchars($structure['nom']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nom <strong>*</strong></span>
|
||||
<input id="new-agent-name" name="nom" type="text" maxlength="100" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Prénom <strong>*</strong></span>
|
||||
<input id="new-agent-firstname" name="prenom" type="text" maxlength="100" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>E-mail</span>
|
||||
<input id="new-agent-email" name="email" type="email" maxlength="255" placeholder="prenom.nom@exemple.fr">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Téléphone</span>
|
||||
<input id="new-agent-phone" name="telephone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Qualification</span>
|
||||
<select id="new-agent-qualified" name="est_diplome">
|
||||
<option value="0">Non diplômé</option>
|
||||
<option value="1">Diplômé</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Diplôme</span>
|
||||
<input id="new-agent-diploma" name="diplome_libelle" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…" disabled>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Adresse personnelle</span>
|
||||
<textarea id="new-agent-address" name="adresse" maxlength="255" rows="2" placeholder="Adresse de l’agent"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Quotité de travail <strong>*</strong></span>
|
||||
<div class="input-suffix">
|
||||
<input id="new-agent-rate" name="quotite_travail" type="number" min="1" max="100" step="0.01" value="100" required>
|
||||
<span>%</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de début du contrat <strong>*</strong></span>
|
||||
<input id="new-agent-contract-start" name="date_debut_contrat" type="date" value="<?= date('Y-m-d') ?>" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de fin du contrat</span>
|
||||
<input id="new-agent-contract-end" name="date_fin_contrat" type="date">
|
||||
</label>
|
||||
<label class="check-field admin-field-wide">
|
||||
<input id="new-agent-training-right" type="checkbox" checked>
|
||||
<span>Droit à l’enveloppe de formation de 14 h. Décoché, le motif « Formation » ne pourra pas être affecté à cet agent lors de la saisie des horaires.</span>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Observations utiles à la construction du PTA</span>
|
||||
<textarea id="new-agent-pta-comment" maxlength="1000" rows="3" placeholder="Disponibilités particulières, organisation connue…"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">Le quota PTA est suivi sur l’année civile, du 1er janvier au 31 décembre. À 100 %, la référence est de 1 607 h. La date de début du contrat reste une information RH mais ne décale pas l’année de référence.</p>
|
||||
<div class="admin-form-actions">
|
||||
<div id="create-agent-message" class="message" role="status" aria-live="polite"></div>
|
||||
<button class="button button-primary" type="submit">Ajouter l’agent</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Paramètres des lieux d’affectation</h2>
|
||||
<p class="section-subtitle">Le type d’affectation est défini au niveau du lieu : par exemple « Crèche Matusalème — Périscolaire ».</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="structure-settings-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table assignment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lieu</th>
|
||||
<th>Code</th>
|
||||
<th>Type d’affectation</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="structure-settings-body">
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<tr data-structure-id="<?= (int) $structure['id_structure'] ?>">
|
||||
<td><strong><?= htmlspecialchars($structure['nom']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($structure['code']) ?></td>
|
||||
<td>
|
||||
<select class="structure-type-select" aria-label="Type d’affectation de <?= htmlspecialchars($structure['nom']) ?>">
|
||||
<option value="PERISCOLAIRE" <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'PERISCOLAIRE' ? 'selected' : '' ?>>Périscolaire</option>
|
||||
<option value="EXTRASCOLAIRE" <?= ($structure['type_affectation'] ?? '') === 'EXTRASCOLAIRE' ? 'selected' : '' ?>>Extrascolaire</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><button type="button" class="button button-secondary save-structure-type">Enregistrer</button></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card coverage-settings-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
@@ -216,151 +43,4 @@
|
||||
<button id="save-coverage-rules" class="button button-primary" type="button" disabled>Enregistrer les horaires de couverture</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Informations des agents</h2>
|
||||
<p class="section-subtitle">Consultez la qualification, les coordonnées et le lieu principal de chaque agent. Le bouton Modifier ouvre la fiche complète.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="assignment-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table assignment-table agent-information-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Matricule</th>
|
||||
<th>Poste</th>
|
||||
<th>Qualification</th>
|
||||
<th>Téléphone</th>
|
||||
<th>Début contrat</th>
|
||||
<th>Lieu principal</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agent-assignment-body">
|
||||
<?php foreach ($agents as $agent): ?>
|
||||
<tr
|
||||
data-agent-id="<?= (int) $agent['id_agent'] ?>"
|
||||
data-email="<?= htmlspecialchars((string) ($agent['email'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-telephone="<?= htmlspecialchars((string) ($agent['telephone'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-adresse="<?= htmlspecialchars((string) ($agent['adresse'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-est-diplome="<?= !empty($agent['est_diplome']) ? '1' : '0' ?>"
|
||||
data-diplome-libelle="<?= htmlspecialchars((string) ($agent['diplome_libelle'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-date-debut-contrat="<?= htmlspecialchars((string) ($agent['date_debut_contrat'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-date-fin-contrat="<?= htmlspecialchars((string) ($agent['date_fin_contrat'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
|
||||
data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>"
|
||||
data-droit-enveloppe-formation="<?= !isset($agent['droit_enveloppe_formation']) || !empty($agent['droit_enveloppe_formation']) ? '1' : '0' ?>"
|
||||
data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
|
||||
>
|
||||
<td><strong class="agent-row-name"><?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($agent['matricule']) ?></td>
|
||||
<td class="agent-row-post"><?= htmlspecialchars((string) (($agent['poste_libelle'] ?? '') ?: 'Non renseigné')) ?></td>
|
||||
<td class="agent-row-qualification">
|
||||
<?= !empty($agent['est_diplome'])
|
||||
? 'Diplômé' . (!empty($agent['diplome_libelle']) ? ' — ' . htmlspecialchars($agent['diplome_libelle']) : '')
|
||||
: 'Non diplômé' ?>
|
||||
</td>
|
||||
<td class="agent-row-phone"><?= htmlspecialchars((string) (($agent['telephone'] ?? '') ?: 'Non renseigné')) ?></td>
|
||||
<td class="agent-row-contract-start"><?= htmlspecialchars((string) (($agent['date_debut_contrat'] ?? '') ?: 'Non renseignée')) ?></td>
|
||||
<td class="agent-row-structure"><?= htmlspecialchars((string) (($agent['structure_nom'] ?? '') ?: 'Sans lieu principal')) ?></td>
|
||||
<td><button type="button" class="button button-secondary edit-agent-profile">Modifier</button></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div id="agent-profile-modal" class="modal-backdrop" hidden>
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="agent-profile-modal-title">
|
||||
<div class="section-heading modal-heading">
|
||||
<div>
|
||||
<h2 id="agent-profile-modal-title">Modifier les informations de l’agent</h2>
|
||||
<p id="agent-profile-modal-subtitle" class="section-subtitle"></p>
|
||||
</div>
|
||||
<button id="close-agent-profile-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<form id="agent-profile-form" class="admin-form">
|
||||
<input id="edit-agent-profile-id" type="hidden">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Poste PTA <strong>*</strong></span>
|
||||
<select id="edit-agent-profile-post" required>
|
||||
<option value="">Choisir un poste</option>
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Type de contrat <strong>*</strong></span>
|
||||
<select id="edit-agent-profile-contract-type" required>
|
||||
<option value="PERMANENT">Permanent</option>
|
||||
<option value="TEMPORAIRE">Temporaire</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>E-mail</span>
|
||||
<input id="edit-agent-profile-email" type="email" maxlength="255">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Téléphone</span>
|
||||
<input id="edit-agent-profile-phone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Qualification</span>
|
||||
<select id="edit-agent-profile-qualified">
|
||||
<option value="0">Non diplômé</option>
|
||||
<option value="1">Diplômé</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Diplôme</span>
|
||||
<input id="edit-agent-profile-diploma" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…">
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Adresse personnelle</span>
|
||||
<textarea id="edit-agent-profile-address" maxlength="255" rows="3"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de début du contrat <strong>*</strong></span>
|
||||
<input id="edit-agent-profile-contract-start" type="date" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de fin du contrat</span>
|
||||
<input id="edit-agent-profile-contract-end" type="date">
|
||||
</label>
|
||||
<label class="check-field admin-field-wide">
|
||||
<input id="edit-agent-profile-training-right" type="checkbox">
|
||||
<span>Droit à l’enveloppe de formation de 14 h</span>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Observations PTA</span>
|
||||
<textarea id="edit-agent-profile-pta-comment" maxlength="1000" rows="3"></textarea>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Lieu d’affectation principal</span>
|
||||
<select id="edit-agent-profile-structure">
|
||||
<option value="">Sans lieu principal</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>">
|
||||
<?= htmlspecialchars($structure['nom']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="agent-profile-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="admin-form-actions modal-actions">
|
||||
<button id="cancel-agent-profile" type="button" class="button button-secondary">Annuler</button>
|
||||
<button type="submit" class="button button-primary">Enregistrer les informations</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<span class="dashboard-tile-label">Lieux à paramétrer</span>
|
||||
<strong class="dashboard-tile-value" id="dashboard-setup-count">—</strong>
|
||||
<small class="dashboard-tile-detail" id="dashboard-setup-detail">Sans horaires de couverture attendus</small>
|
||||
<span class="dashboard-tile-cta">Ouvrir l’administration</span>
|
||||
<span class="dashboard-tile-cta">Ouvrir l'administration</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
</div>
|
||||
<a class="button button-secondary" href="pending.php">Tout voir</a>
|
||||
</div>
|
||||
<div id="dashboard-pending-empty" class="empty-state" hidden>Aucun planning n’attend de validation.</div>
|
||||
<div id="dashboard-pending-empty" class="empty-state" hidden>Aucun planning n'attend de validation.</div>
|
||||
<ul class="dashboard-list" id="dashboard-pending-list"></ul>
|
||||
</section>
|
||||
|
||||
@@ -154,9 +154,9 @@
|
||||
<strong>Ouvrir un PTA détaillé</strong>
|
||||
<small>Souhaits, contraintes et répartition.</small>
|
||||
</a>
|
||||
<a class="dashboard-shortcut" href="administration.php">
|
||||
<strong>Gérer les référentiels</strong>
|
||||
<small>Agents, lieux, postes, couverture.</small>
|
||||
<a class="dashboard-shortcut" href="manage_agents.php">
|
||||
<strong>Gérer les agents</strong>
|
||||
<small>Créer, corriger une fiche, changer un rattachement.</small>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
229
app/Views/partials/dsi.php
Normal file
229
app/Views/partials/dsi.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* Espace DSI : comptes d'accès, jetons et journal d'activité.
|
||||
* Module JS : `dsi`.
|
||||
*
|
||||
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
|
||||
* posés ici sont les points d'ancrage du module JS : les renommer casse le
|
||||
* module sans aucune erreur visible.
|
||||
*
|
||||
* Aucune donnée n'est rendue côté PHP : la page est entièrement alimentée par
|
||||
* api/dsi_comptes.php et api/dsi_journal.php. C'est ce qui permet de rafraîchir
|
||||
* le journal sans recharger l'écran, et d'éviter que le HTML servi porte la
|
||||
* moindre information sur les comptes.
|
||||
*/
|
||||
?>
|
||||
<section class="page-content dsi-page">
|
||||
<div id="dsi-installation-warning" class="message message-error dsi-installation-warning" role="alert" hidden>
|
||||
Le compte DSI utilise encore le jeton d'installation, publié en clair dans la migration SQL :
|
||||
<strong>régénérez-le maintenant</strong>, il donne un accès complet à cette page.
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Panneau des jetons fraîchement produits. C'est la SEULE occasion de les
|
||||
lire : la base n'en conserve qu'une empreinte. Il reste ouvert tant que
|
||||
la DSI ne l'a pas explicitement refermé, pour laisser le temps de les
|
||||
recopier ou de les imprimer.
|
||||
-->
|
||||
<section id="dsi-token-panel" class="card dsi-token-panel" hidden>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Jetons à transmettre</h2>
|
||||
<p class="section-subtitle">Ces jetons ne seront plus jamais affichés : la base n'en garde qu'une empreinte. Copiez-les, transmettez-les à leurs titulaires, puis refermez ce panneau.</p>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button id="dsi-token-copy" class="button button-secondary" type="button">Tout copier</button>
|
||||
<button id="dsi-token-close" class="button button-secondary" type="button">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dsi-token-list" class="dsi-token-list"></div>
|
||||
<div id="dsi-token-message" class="message" role="status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Comptes d'accès</h2>
|
||||
<p class="section-subtitle">Un compte par utilisateur : agents, responsables de lieu, administrateurs du Service Enfance et DSI. Chacun se connecte avec son jeton, il n’y a aucun mot de passe dans PTA.</p>
|
||||
</div>
|
||||
<button id="dsi-generate-missing" class="button button-secondary" type="button">
|
||||
Générer les jetons manquants <span id="dsi-missing-count" class="nav-count-badge" hidden>0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="dsi-account-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Type de compte</span>
|
||||
<select id="dsi-account-role" required>
|
||||
<option value="SERVICE_ENFANCE">Service Enfance (administrateur)</option>
|
||||
<option value="DSI">DSI</option>
|
||||
<option value="AGENT">Agent</option>
|
||||
<option value="RESPONSABLE_STRUCTURE">Responsable de structure</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field" id="dsi-account-agent-field" hidden>
|
||||
<span>Agent titulaire</span>
|
||||
<select id="dsi-account-agent"><option value="">Choisir un agent sans compte</option></select>
|
||||
</label>
|
||||
<label class="field" id="dsi-account-structure-field" hidden>
|
||||
<span>Lieu titulaire</span>
|
||||
<select id="dsi-account-structure"><option value="">Choisir un lieu sans compte</option></select>
|
||||
</label>
|
||||
<label class="field" id="dsi-account-label-field">
|
||||
<span>Nom du titulaire</span>
|
||||
<input id="dsi-account-label" type="text" maxlength="180" placeholder="Ex. Service Enfance — Camille Roux">
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Commentaire (facultatif)</span>
|
||||
<input id="dsi-account-comment" type="text" maxlength="255" placeholder="Ex. remplacement congé maternité, jusqu'en septembre">
|
||||
</label>
|
||||
</div>
|
||||
<p id="dsi-account-hint" class="hint"></p>
|
||||
<div class="admin-form-actions">
|
||||
<div id="dsi-account-message" class="message" role="status" aria-live="polite"></div>
|
||||
<button class="button button-primary" type="submit">Créer le compte et générer son jeton</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="selection-grid overview-filters">
|
||||
<label class="field">
|
||||
<span>Type</span>
|
||||
<select id="dsi-filter-role">
|
||||
<option value="">Tous les types</option>
|
||||
<option value="DSI">DSI</option>
|
||||
<option value="SERVICE_ENFANCE">Service Enfance</option>
|
||||
<option value="RESPONSABLE_STRUCTURE">Responsable de structure</option>
|
||||
<option value="AGENT">Agent</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>État</span>
|
||||
<select id="dsi-filter-state">
|
||||
<option value="">Tous</option>
|
||||
<option value="1">Actifs</option>
|
||||
<option value="0">Désactivés</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Rechercher</span>
|
||||
<input id="dsi-filter-search" type="search" maxlength="80" placeholder="Nom, matricule ou début de jeton">
|
||||
</label>
|
||||
<div class="overview-filter-actions">
|
||||
<button id="dsi-accounts-refresh" class="button button-secondary filter-button" type="button">Actualiser</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dsi-accounts-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table dsi-accounts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titulaire</th>
|
||||
<th>Type</th>
|
||||
<th>Jeton</th>
|
||||
<th>Dernière connexion</th>
|
||||
<th>État</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dsi-accounts-body">
|
||||
<tr><td colspan="6">Chargement des comptes…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Journal d'activité</h2>
|
||||
<p class="section-subtitle">Connexions, écritures en base et refus d'accès. Les lectures ne sont pas tracées : elles noieraient le journal sans rien apprendre.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dsi-journal-stats" class="summary-grid compact-summary"></div>
|
||||
|
||||
<div class="selection-grid overview-filters dsi-journal-filters">
|
||||
<label class="field">
|
||||
<span>Catégorie</span>
|
||||
<select id="dsi-journal-category">
|
||||
<option value="">Toutes</option>
|
||||
<option value="CONNEXION">Connexions</option>
|
||||
<option value="ECRITURE">Écritures</option>
|
||||
<option value="ACCES_REFUSE">Refus d'accès</option>
|
||||
<option value="ADMINISTRATION">Administration</option>
|
||||
<option value="SYSTEME">Système</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Résultat</span>
|
||||
<select id="dsi-journal-result">
|
||||
<option value="">Tous</option>
|
||||
<option value="SUCCES">Succès</option>
|
||||
<option value="REFUS">Refus</option>
|
||||
<option value="ERREUR">Erreur</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Rôle</span>
|
||||
<select id="dsi-journal-role">
|
||||
<option value="">Tous</option>
|
||||
<option value="DSI">DSI</option>
|
||||
<option value="SERVICE_ENFANCE">Service Enfance</option>
|
||||
<option value="RESPONSABLE_STRUCTURE">Responsable de structure</option>
|
||||
<option value="AGENT">Agent</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Du</span>
|
||||
<input id="dsi-journal-from" type="date">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Au</span>
|
||||
<input id="dsi-journal-to" type="date">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Rechercher</span>
|
||||
<input id="dsi-journal-search" type="search" maxlength="80" placeholder="Acteur, action, adresse IP">
|
||||
</label>
|
||||
<div class="overview-filter-actions">
|
||||
<button id="dsi-journal-refresh" class="button button-secondary filter-button" type="button">Actualiser</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dsi-journal-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table dsi-journal-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Catégorie</th>
|
||||
<th>Acteur</th>
|
||||
<th>Action</th>
|
||||
<th>Détail</th>
|
||||
<th>Origine</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dsi-journal-body">
|
||||
<tr><td colspan="6">Chargement du journal…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="dsi-pagination">
|
||||
<button id="dsi-journal-previous" class="button button-secondary" type="button" disabled>Page précédente</button>
|
||||
<span id="dsi-journal-page-label">Page 1</span>
|
||||
<button id="dsi-journal-next" class="button button-secondary" type="button" disabled>Page suivante</button>
|
||||
</div>
|
||||
|
||||
<form id="dsi-purge-form" class="inline-form dsi-purge-form">
|
||||
<label class="field">
|
||||
<span>Purger les entrées de plus de</span>
|
||||
<input id="dsi-purge-days" type="number" min="30" max="3650" value="365" required>
|
||||
</label>
|
||||
<span class="hint">jours (30 minimum : une purge trop courte effacerait les traces d'un incident avant qu'il soit constaté).</span>
|
||||
<button class="button button-danger" type="submit">Purger</button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
@@ -19,16 +19,16 @@ $roleActuel = (string) ($accessProfile['role'] ?? '');
|
||||
* droit, plutôt que de masquer les étapes inaccessibles.
|
||||
*/
|
||||
$sommaire = [
|
||||
'faq-parcours' => 'Le parcours en un coup d’œil',
|
||||
'faq-roles' => 'Qui fait quoi : les trois rôles',
|
||||
'faq-lieux' => '1. Ajouter un lieu d’affectation',
|
||||
'faq-parcours' => 'Le parcours en un coup d\'œil',
|
||||
'faq-roles' => 'Qui fait quoi : les quatre rôles',
|
||||
'faq-lieux' => '1. Ajouter un lieu d\'affectation',
|
||||
'faq-agents' => '2. Ajouter un agent',
|
||||
'faq-couverture' => '3. Définir les horaires d’ouverture',
|
||||
'faq-couverture' => '3. Définir les horaires d\'ouverture',
|
||||
'faq-vacances' => '4. Renseigner les vacances scolaires',
|
||||
'faq-saisie' => '5. Saisir un planning pour un agent',
|
||||
'faq-validation' => '6. Contrôler et valider un planning',
|
||||
'faq-equipes' => '7. Composer les équipes',
|
||||
'faq-suivi' => '8. Suivre l’année et éditer les PDF',
|
||||
'faq-suivi' => '8. Suivre l\'année et éditer les PDF',
|
||||
'faq-quota' => 'Comprendre le quota annuel et les motifs',
|
||||
'faq-questions' => 'Questions fréquentes',
|
||||
];
|
||||
@@ -73,8 +73,8 @@ $sommaire = [
|
||||
<article class="card faq-section" id="faq-roles">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Qui fait quoi : les trois rôles</h2>
|
||||
<p class="section-subtitle">Votre rôle détermine les onglets visibles et les actions autorisées. Il se choisit à l’ouverture de l’application et se change à tout moment par le bouton « Changer de rôle », en haut à droite.</p>
|
||||
<h2>Qui fait quoi : les quatre rôles</h2>
|
||||
<p class="section-subtitle">Votre rôle détermine les onglets visibles et les actions autorisées. Il ne se choisit pas : il est porté par le jeton d’accès que la DSI vous a remis, et que vous saisissez à l’ouverture de l’application. Le bouton « Se déconnecter », en haut à droite, referme la session.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
@@ -99,24 +99,31 @@ $sommaire = [
|
||||
</tr>
|
||||
<tr<?= $roleActuel === 'SERVICE_ENFANCE' ? ' class="faq-row-current"' : '' ?>>
|
||||
<td><strong>Service Enfance</strong></td>
|
||||
<td>Tous les lieux, tous les agents, tous les écrans.</td>
|
||||
<td>Tous les lieux, tous les agents, tous les écrans métier.</td>
|
||||
<td>Paramétrer, saisir, composer les équipes, <strong>valider</strong> et rouvrir les plannings.</td>
|
||||
</tr>
|
||||
<tr<?= $roleActuel === 'DSI' ? ' class="faq-row-current"' : '' ?>>
|
||||
<td><strong>DSI</strong></td>
|
||||
<td>Uniquement les comptes d’accès et le journal d’activité. Aucun planning, aucun agent.</td>
|
||||
<td>Créer, désactiver et régénérer les jetons ; consulter les connexions, les écritures et les refus d’accès.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">Un planning validé n’est plus modifiable par un responsable de structure : il faut demander au Service Enfance de le rouvrir, ce qui le repasse en brouillon.</p>
|
||||
<p class="hint"><strong>Comptes Service Enfance et DSI :</strong> le jeton ne suffit pas. Un mot de passe, que vous choisissez à votre première connexion et que personne d’autre ne connaît, vous est demandé ensuite. Le bouton « Mot de passe », en haut à droite, permet d’en changer ; en cas d’oubli, la DSI l’efface et vous en choisissez un nouveau.</p>
|
||||
<p class="hint"><strong>Votre jeton vaut votre signature :</strong> toutes vos écritures sont journalisées à votre nom. Il ne se prête pas. Jeton perdu ou divulgué, la DSI en génère un nouveau, ce qui rend l’ancien inutilisable sur-le-champ.</p>
|
||||
</article>
|
||||
|
||||
<article class="card faq-section" id="faq-lieux">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>1. Ajouter un lieu d’affectation</h2>
|
||||
<p class="section-subtitle">Onglet <strong>Administration</strong> — réservé au Service Enfance. Un lieu doit exister avant qu’un agent puisse y être rattaché ou planifié.</p>
|
||||
<p class="section-subtitle">Groupe <strong>Management</strong>, onglet <strong>Lieux d’affectation</strong> — réservé au Service Enfance. Un lieu doit exister avant qu’un agent puisse y être rattaché ou planifié.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="faq-steps">
|
||||
<li>Ouvrez <strong>Administration</strong>, encadré « Ajouter un lieu d’affectation ».</li>
|
||||
<li>Ouvrez <strong>Management → Lieux d’affectation</strong>, encadré « Ajouter un lieu d’affectation ».</li>
|
||||
<li>Saisissez le <strong>code</strong> : un identifiant court et stable, sans espace, par exemple <code>CRECHE_MATUSALEME</code>. Il ne s’affiche que dans les tableaux techniques.</li>
|
||||
<li>Saisissez le <strong>nom</strong> tel qu’il doit apparaître partout ailleurs, par exemple « Crèche Matusalème ».</li>
|
||||
<li>Choisissez le <strong>type d’affectation</strong> : <em>Périscolaire</em> (autour du temps scolaire) ou <em>Extrascolaire</em> (mercredis et vacances). Ce choix appartient au lieu, pas à l’agent.</li>
|
||||
@@ -132,7 +139,7 @@ $sommaire = [
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>2. Ajouter un agent</h2>
|
||||
<p class="section-subtitle">Onglet <strong>Administration</strong> — réservé au Service Enfance. La fiche agent détermine son quota annuel et les motifs qu’on pourra lui affecter.</p>
|
||||
<p class="section-subtitle">Groupe <strong>Management</strong>, onglet <strong>Agents</strong> — réservé au Service Enfance. La fiche agent détermine son quota annuel et les motifs qu’on pourra lui affecter.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="faq-steps">
|
||||
@@ -147,7 +154,8 @@ $sommaire = [
|
||||
<li><strong>Observations utiles à la construction du PTA</strong> : disponibilités particulières, organisation connue… Ces notes se retrouvent sur l’écran « PTA détaillé ».</li>
|
||||
<li>Cliquez sur <strong>Ajouter l’agent</strong>.</li>
|
||||
</ol>
|
||||
<p class="hint">Pour corriger une fiche existante, utilisez le tableau « Informations des agents » en bas de la page : le bouton <strong>Modifier</strong> ouvre la fiche complète, lieu principal compris.</p>
|
||||
<p class="hint"><strong>Dates de contrat :</strong> elles bornent la planification. Aucune affectation — pas même un congé — ne peut être posée avant la date de début ou après la date de fin. Un contrat sans date de fin est considéré comme à durée indéterminée. Raccourcir un contrat est refusé tant qu’il reste des affectations au-delà : supprimez-les d’abord.</p>
|
||||
<p class="hint">Pour corriger une fiche existante, utilisez le tableau « Informations des agents » du même onglet : le bouton <strong>Modifier</strong> ouvre la fiche complète, nom, prénom et lieu principal compris. Seuls le <strong>matricule</strong> et la <strong>quotité de travail</strong> restent hors d’atteinte : le premier sert de clé avec les outils RH, la seconde détermine le quota déjà consommé de l’année.</p>
|
||||
</article>
|
||||
|
||||
<article class="card faq-section" id="faq-couverture">
|
||||
@@ -388,7 +396,7 @@ $sommaire = [
|
||||
</div>
|
||||
<dl class="faq-qa">
|
||||
<dt>Je ne vois pas l’onglet dont on me parle.</dt>
|
||||
<dd>Les onglets dépendent du rôle sélectionné. Vérifiez le badge en haut à droite et, si nécessaire, utilisez « Changer de rôle ». Si l’onglet reste absent, votre rôle n’y a pas droit.</dd>
|
||||
<dd>Les onglets dépendent du rôle porté par votre jeton. Vérifiez le badge en haut à droite : si l’onglet attendu n’apparaît pas, votre rôle n’y a pas droit, et c’est la DSI qui délivre un autre accès.</dd>
|
||||
|
||||
<dt>Impossible de choisir un agent dans l’écran de saisie.</dt>
|
||||
<dd>Il faut d’abord choisir un lieu d’affectation : la liste des agents en dépend. La semaine ne se débloque qu’une fois l’agent choisi.</dd>
|
||||
|
||||
278
app/Views/partials/manage_agents.php
Normal file
278
app/Views/partials/manage_agents.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
/**
|
||||
* Management — Agents : création, liste et fiche complète des agents.
|
||||
* Module JS : `administration`.
|
||||
*
|
||||
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
|
||||
* posés ici sont les points d'ancrage du module JS : les renommer casse le
|
||||
* module sans aucune erreur visible.
|
||||
*
|
||||
* V7.14 : ce contenu vivait dans le partiel « administration », qui mêlait les
|
||||
* agents, les lieux et les horaires de couverture. Chaque objet a désormais son
|
||||
* onglet, sous le groupe « Management » de la barre de navigation.
|
||||
*
|
||||
* L'ordre est celui du travail réel : on crée un agent, puis on consulte ou
|
||||
* corrige sa fiche dans le tableau qui suit.
|
||||
*/
|
||||
?>
|
||||
<section id="manage-agents-view" class="app-view page-content">
|
||||
<section class="card admin-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Ajouter un agent</h2>
|
||||
<p class="section-subtitle">L’agent reçoit un lieu principal à sa création, mais peut être intégré aussi bien aux équipes périscolaires qu’extrascolaires et travailler ponctuellement sur d’autres lieux.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="create-agent-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Matricule <strong>*</strong></span>
|
||||
<input id="new-agent-matricule" name="matricule" type="text" maxlength="50" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Poste PTA <strong>*</strong></span>
|
||||
<select id="new-agent-post" name="id_poste" required>
|
||||
<option value="">Choisir un poste</option>
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Type de contrat <strong>*</strong></span>
|
||||
<select id="new-agent-contract-type" required>
|
||||
<option value="PERMANENT">Permanent — soumis au PTA</option>
|
||||
<option value="TEMPORAIRE">Temporaire — hors périmètre PTA</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Lieu d’affectation principal <strong>*</strong></span>
|
||||
<select id="new-agent-structure" name="structure_id" required>
|
||||
<option value="">Choisir un lieu d’affectation</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>">
|
||||
<?= htmlspecialchars($structure['nom']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nom <strong>*</strong></span>
|
||||
<input id="new-agent-name" name="nom" type="text" maxlength="100" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Prénom <strong>*</strong></span>
|
||||
<input id="new-agent-firstname" name="prenom" type="text" maxlength="100" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>E-mail</span>
|
||||
<input id="new-agent-email" name="email" type="email" maxlength="255" placeholder="prenom.nom@exemple.fr">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Téléphone</span>
|
||||
<input id="new-agent-phone" name="telephone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Qualification</span>
|
||||
<select id="new-agent-qualified" name="est_diplome">
|
||||
<option value="0">Non diplômé</option>
|
||||
<option value="1">Diplômé</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Diplôme</span>
|
||||
<input id="new-agent-diploma" name="diplome_libelle" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…" disabled>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Adresse personnelle</span>
|
||||
<textarea id="new-agent-address" name="adresse" maxlength="255" rows="2" placeholder="Adresse de l’agent"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Quotité de travail <strong>*</strong></span>
|
||||
<div class="input-suffix">
|
||||
<input id="new-agent-rate" name="quotite_travail" type="number" min="1" max="100" step="0.01" value="100" required>
|
||||
<span>%</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de début du contrat <strong>*</strong></span>
|
||||
<input id="new-agent-contract-start" name="date_debut_contrat" type="date" value="<?= date('Y-m-d') ?>" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de fin du contrat</span>
|
||||
<input id="new-agent-contract-end" name="date_fin_contrat" type="date">
|
||||
</label>
|
||||
<label class="check-field admin-field-wide">
|
||||
<input id="new-agent-training-right" type="checkbox" checked>
|
||||
<span>Droit à l’enveloppe de formation de 14 h. Décoché, le motif « Formation » ne pourra pas être affecté à cet agent lors de la saisie des horaires.</span>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Observations utiles à la construction du PTA</span>
|
||||
<textarea id="new-agent-pta-comment" maxlength="1000" rows="3" placeholder="Disponibilités particulières, organisation connue…"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">Le quota PTA est suivi sur l’année civile, du 1er janvier au 31 décembre. À 100 %, la référence est de 1 607 h. La date de début du contrat reste une information RH mais ne décale pas l’année de référence.</p>
|
||||
<div class="admin-form-actions">
|
||||
<div id="create-agent-message" class="message" role="status" aria-live="polite"></div>
|
||||
<button class="button button-primary" type="submit">Ajouter l’agent</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Informations des agents</h2>
|
||||
<p class="section-subtitle">Consultez la qualification, les coordonnées et le lieu principal de chaque agent. Le bouton Modifier ouvre la fiche complète.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="assignment-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table assignment-table agent-information-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Matricule</th>
|
||||
<th>Poste</th>
|
||||
<th>Qualification</th>
|
||||
<th>Téléphone</th>
|
||||
<th>Début contrat</th>
|
||||
<th>Lieu principal</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agent-assignment-body">
|
||||
<?php foreach ($agents as $agent): ?>
|
||||
<tr
|
||||
data-agent-id="<?= (int) $agent['id_agent'] ?>"
|
||||
data-prenom="<?= htmlspecialchars((string) $agent['prenom'], ENT_QUOTES) ?>"
|
||||
data-nom="<?= htmlspecialchars((string) $agent['nom'], ENT_QUOTES) ?>"
|
||||
data-email="<?= htmlspecialchars((string) ($agent['email'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-telephone="<?= htmlspecialchars((string) ($agent['telephone'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-adresse="<?= htmlspecialchars((string) ($agent['adresse'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-est-diplome="<?= !empty($agent['est_diplome']) ? '1' : '0' ?>"
|
||||
data-diplome-libelle="<?= htmlspecialchars((string) ($agent['diplome_libelle'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-date-debut-contrat="<?= htmlspecialchars((string) ($agent['date_debut_contrat'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-date-fin-contrat="<?= htmlspecialchars((string) ($agent['date_fin_contrat'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
|
||||
data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>"
|
||||
data-droit-enveloppe-formation="<?= !isset($agent['droit_enveloppe_formation']) || !empty($agent['droit_enveloppe_formation']) ? '1' : '0' ?>"
|
||||
data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>"
|
||||
data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
|
||||
>
|
||||
<td><strong class="agent-row-name"><?= htmlspecialchars($agent['prenom'] . ' ' . $agent['nom']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($agent['matricule']) ?></td>
|
||||
<td class="agent-row-post"><?= htmlspecialchars((string) (($agent['poste_libelle'] ?? '') ?: 'Non renseigné')) ?></td>
|
||||
<td class="agent-row-qualification">
|
||||
<?= !empty($agent['est_diplome'])
|
||||
? 'Diplômé' . (!empty($agent['diplome_libelle']) ? ' — ' . htmlspecialchars($agent['diplome_libelle']) : '')
|
||||
: 'Non diplômé' ?>
|
||||
</td>
|
||||
<td class="agent-row-phone"><?= htmlspecialchars((string) (($agent['telephone'] ?? '') ?: 'Non renseigné')) ?></td>
|
||||
<td class="agent-row-contract-start"><?= htmlspecialchars((string) (($agent['date_debut_contrat'] ?? '') ?: 'Non renseignée')) ?></td>
|
||||
<td class="agent-row-structure"><?= htmlspecialchars((string) (($agent['structure_nom'] ?? '') ?: 'Sans lieu principal')) ?></td>
|
||||
<td><button type="button" class="button button-secondary edit-agent-profile">Modifier</button></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div id="agent-profile-modal" class="modal-backdrop" hidden>
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="agent-profile-modal-title">
|
||||
<div class="section-heading modal-heading">
|
||||
<div>
|
||||
<h2 id="agent-profile-modal-title">Modifier les informations de l’agent</h2>
|
||||
<p id="agent-profile-modal-subtitle" class="section-subtitle"></p>
|
||||
</div>
|
||||
<button id="close-agent-profile-modal" type="button" class="button button-ghost modal-close" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<form id="agent-profile-form" class="admin-form">
|
||||
<input id="edit-agent-profile-id" type="hidden">
|
||||
<div class="admin-form-grid">
|
||||
<?php /* Le matricule reste absent : c'est la clé de rapprochement avec les outils RH. */ ?>
|
||||
<label class="field">
|
||||
<span>Prénom <strong>*</strong></span>
|
||||
<input id="edit-agent-profile-firstname" type="text" maxlength="100" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nom <strong>*</strong></span>
|
||||
<input id="edit-agent-profile-lastname" type="text" maxlength="100" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Poste PTA <strong>*</strong></span>
|
||||
<select id="edit-agent-profile-post" required>
|
||||
<option value="">Choisir un poste</option>
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<option value="<?= (int) $post['id_poste'] ?>"><?= htmlspecialchars($post['libelle']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Type de contrat <strong>*</strong></span>
|
||||
<select id="edit-agent-profile-contract-type" required>
|
||||
<option value="PERMANENT">Permanent</option>
|
||||
<option value="TEMPORAIRE">Temporaire</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>E-mail</span>
|
||||
<input id="edit-agent-profile-email" type="email" maxlength="255">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Téléphone</span>
|
||||
<input id="edit-agent-profile-phone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Qualification</span>
|
||||
<select id="edit-agent-profile-qualified">
|
||||
<option value="0">Non diplômé</option>
|
||||
<option value="1">Diplômé</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Diplôme</span>
|
||||
<input id="edit-agent-profile-diploma" type="text" maxlength="150" placeholder="Ex. BAFA, CAP AEPE…">
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Adresse personnelle</span>
|
||||
<textarea id="edit-agent-profile-address" maxlength="255" rows="3"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de début du contrat <strong>*</strong></span>
|
||||
<input id="edit-agent-profile-contract-start" type="date" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date de fin du contrat</span>
|
||||
<input id="edit-agent-profile-contract-end" type="date">
|
||||
</label>
|
||||
<label class="check-field admin-field-wide">
|
||||
<input id="edit-agent-profile-training-right" type="checkbox">
|
||||
<span>Droit à l’enveloppe de formation de 14 h</span>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Observations PTA</span>
|
||||
<textarea id="edit-agent-profile-pta-comment" maxlength="1000" rows="3"></textarea>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Lieu d’affectation principal</span>
|
||||
<select id="edit-agent-profile-structure">
|
||||
<option value="">Sans lieu principal</option>
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<option value="<?= (int) $structure['id_structure'] ?>">
|
||||
<?= htmlspecialchars($structure['nom']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="agent-profile-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="admin-form-actions modal-actions">
|
||||
<button id="cancel-agent-profile" type="button" class="button button-secondary">Annuler</button>
|
||||
<button type="submit" class="button button-primary">Enregistrer les informations</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
87
app/Views/partials/manage_structures.php
Normal file
87
app/Views/partials/manage_structures.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* Management — Lieux d'affectation : création et paramètres des lieux.
|
||||
* Module JS : `administration`.
|
||||
*
|
||||
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
|
||||
* posés ici sont les points d'ancrage du module JS : les renommer casse le
|
||||
* module sans aucune erreur visible.
|
||||
*
|
||||
* Les HORAIRES DE COUVERTURE d'un lieu ne sont pas ici mais dans l'onglet
|
||||
* « Administration » : ils ne décrivent pas le lieu, ils décrivent une exigence
|
||||
* de présence, contrôlée par l'onglet « Couverture ».
|
||||
*/
|
||||
?>
|
||||
<section id="manage-structures-view" class="app-view page-content">
|
||||
<section class="card admin-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Ajouter un lieu d’affectation</h2>
|
||||
<p class="section-subtitle">Chaque lieu possède son propre type : périscolaire ou extrascolaire.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="create-structure-form" class="admin-form">
|
||||
<div class="admin-form-grid">
|
||||
<label class="field">
|
||||
<span>Code <strong>*</strong></span>
|
||||
<input id="new-structure-code" name="code" type="text" maxlength="50" placeholder="Ex. CRECHE_MATUSALEME" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nom <strong>*</strong></span>
|
||||
<input id="new-structure-name" name="nom" type="text" maxlength="150" placeholder="Ex. Crèche Matusalème" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Type d’affectation <strong>*</strong></span>
|
||||
<select id="new-structure-type" name="type_affectation" required>
|
||||
<option value="PERISCOLAIRE">Périscolaire</option>
|
||||
<option value="EXTRASCOLAIRE">Extrascolaire</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field admin-field-wide">
|
||||
<span>Adresse</span>
|
||||
<input id="new-structure-address" name="adresse" type="text" maxlength="255" placeholder="Adresse du lieu">
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<div id="create-structure-message" class="message" role="status" aria-live="polite"></div>
|
||||
<button class="button button-primary" type="submit">Ajouter le lieu</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Paramètres des lieux d’affectation</h2>
|
||||
<p class="section-subtitle">Le type d’affectation est défini au niveau du lieu : par exemple « Crèche Matusalème — Périscolaire ».</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="structure-settings-message" class="message" role="status" aria-live="polite"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="planning-table assignment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lieu</th>
|
||||
<th>Code</th>
|
||||
<th>Type d’affectation</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="structure-settings-body">
|
||||
<?php foreach ($structures as $structure): ?>
|
||||
<tr data-structure-id="<?= (int) $structure['id_structure'] ?>">
|
||||
<td><strong><?= htmlspecialchars($structure['nom']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($structure['code']) ?></td>
|
||||
<td>
|
||||
<select class="structure-type-select" aria-label="Type d’affectation de <?= htmlspecialchars($structure['nom']) ?>">
|
||||
<option value="PERISCOLAIRE" <?= ($structure['type_affectation'] ?? 'PERISCOLAIRE') === 'PERISCOLAIRE' ? 'selected' : '' ?>>Périscolaire</option>
|
||||
<option value="EXTRASCOLAIRE" <?= ($structure['type_affectation'] ?? '') === 'EXTRASCOLAIRE' ? 'selected' : '' ?>>Extrascolaire</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><button type="button" class="button button-secondary save-structure-type">Enregistrer</button></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
@@ -15,6 +15,7 @@ declare(strict_types=1);
|
||||
* @return PDO Connexion partagée par les contrôleurs et les modèles.
|
||||
*/
|
||||
|
||||
use App\Core\Journal;
|
||||
use App\Core\Session;
|
||||
use App\Models\SchemaModel;
|
||||
|
||||
@@ -23,4 +24,9 @@ Session::start();
|
||||
$pdo = require dirname(__DIR__) . '/db.php';
|
||||
(new SchemaModel($pdo))->assertCurrent();
|
||||
|
||||
// Le journal d'activité (V7.11) reçoit ici sa connexion, une fois pour toute la
|
||||
// requête : les gardes statiques (Csrf) et les contrôleurs peuvent alors tracer
|
||||
// une action sans avoir à transporter le PDO.
|
||||
Journal::useConnection($pdo);
|
||||
|
||||
return $pdo;
|
||||
|
||||
Reference in New Issue
Block a user