This commit is contained in:
PTA
2026-08-20 17:00:06 +02:00
parent 5d8c0993a6
commit 94e7f09eb4
58 changed files with 5605 additions and 615 deletions

View File

@@ -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,
]);
}

View File

@@ -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,

View File

@@ -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),

View 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'],
];
}
}

View File

@@ -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),
],
]);
}

View File

@@ -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];

View File

@@ -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()]);

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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.']);
}
}

View File

@@ -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.']);
}

View File

@@ -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)) {

View File

@@ -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;
}
/**

View File

@@ -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
View 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);
}
}

View File

@@ -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
View 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
View 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'],
];
}
}

View File

@@ -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(

View File

@@ -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.'
);
}
}
}

View File

@@ -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>

View File

@@ -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&amp;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&amp;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
View 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';

View 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';

View 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';

View File

@@ -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&amp;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>

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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;