V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation

Reprise dans le répertoire de production du travail mené dans le worktree
ux-service-enfance :

- tableau de bord Service Enfance (dashboard.php) et navigation regroupée ;
- guide d'utilisation (faq.php) accessible depuis toute la navigation ;
- éditeur de semaine type mis en avant sur l'écran de saisie ;
- droit à l'enveloppe de formation par agent, en remplacement de la case
  « formation déjà prévue dans la répartition annuelle » ;
- correctif de « [object Object] » sur les cartes de quota du PTA ;
- motif de travail par défaut à la saisie, heures travaillées complètes
  dans les PDF.

Le docker-compose.yml de production est conservé : celui du worktree décrit
une pile de test (conteneurs et volume suffixés « test », ports 9080/9081),
qui aurait fait redémarrer l'application sur un volume MariaDB vide.

Les migrations database/migration_v7_9*.sql sont obligatoires ; le service
db-migrate les applique au déploiement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PTA
2026-08-17 16:53:04 +02:00
parent 57bd8da5c8
commit 4201894e28
69 changed files with 5228 additions and 246 deletions

View File

@@ -6,6 +6,14 @@ namespace App\Core;
use PDO;
/**
* Contrôle des rôles et des périmètres.
*
* Aucune règle de droit n'est écrite ici : les pages autorisées, les
* permissions fonctionnelles et les périmètres de visibilité sont lus dans les
* vues SQL créées par database/migration_v7_8_securite_acces.sql. Cette classe
* ne fait que poser la question à la base et appliquer la réponse.
*/
final class Access
{
public const ROLE_AGENT = 'AGENT';
@@ -14,11 +22,15 @@ final class Access
private const SESSION_KEY = 'pta_access';
/** @var array<string, array<string, bool>> */
private array $permissionCache = [];
/** @var array<string, array<int, string>> */
private array $pageCache = [];
public function __construct(private PDO $pdo)
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
Session::start();
}
public function isConfigured(): bool
@@ -85,13 +97,16 @@ final class Access
$selection['structure_id'] = $structureId;
}
$_SESSION[self::SESSION_KEY] = $selection;
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.
unset($_SESSION['csrf_token']);
}
public function clear(): void
{
unset($_SESSION[self::SESSION_KEY]);
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
session_regenerate_id(true);
}
@@ -128,39 +143,61 @@ final class Access
return $profile;
}
/**
* Permissions fonctionnelles lues dans v_acces_role_permission.
* Une permission absente de la base vaut « refusé ».
*
* @return array<string, bool>
*/
public function permissions(): array
{
$role = $this->role();
$service = $role === self::ROLE_SERVICE;
$responsable = $role === self::ROLE_RESPONSABLE;
if ($role === null) {
return [];
}
return [
'read_only' => $role === self::ROLE_AGENT,
'can_edit_draft' => $service || $responsable,
'can_edit_cross_structure' => $service,
'can_validate' => $service,
'can_reopen' => $service || $responsable,
'can_use_templates' => $service,
'can_copy_full_week' => $service,
'can_view_pending' => $service,
'can_manage_coverage' => $service,
'can_manage_agents' => $service,
'can_manage_structures' => $service,
'can_manage_vacations' => $service,
'can_manage_teams' => $service,
'can_manage_pta' => $service,
'can_forecast_next_year' => $service,
];
if (!isset($this->permissionCache[$role])) {
$stmt = $this->pdo->prepare(
'SELECT permission, autorise FROM v_acces_role_permission WHERE role = :role'
);
$stmt->execute(['role' => $role]);
$permissions = [];
foreach ($stmt->fetchAll() as $row) {
$permissions[(string) $row['permission']] = (bool) $row['autorise'];
}
$this->permissionCache[$role] = $permissions;
}
return $this->permissionCache[$role];
}
public function hasPermission(string $permission): bool
{
return $this->permissions()[$permission] ?? false;
}
/**
* Pages autorisées lues dans v_acces_role_page.
*
* @return array<int, string>
*/
public function allowedPages(): array
{
return match ($this->role()) {
self::ROLE_AGENT => ['agents', 'structures'],
self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'],
self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'],
default => [],
};
$role = $this->role();
if ($role === null) {
return [];
}
if (!isset($this->pageCache[$role])) {
$stmt = $this->pdo->prepare(
'SELECT page FROM v_acces_role_page WHERE role = :role ORDER BY ordre_affichage, page'
);
$stmt->execute(['role' => $role]);
$this->pageCache[$role] = array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
}
return $this->pageCache[$role];
}
public function canAccessPage(string $page): bool
@@ -168,12 +205,21 @@ final class Access
return in_array($page, $this->allowedPages(), true);
}
/**
* Page d'atterrissage du rôle.
*
* Le Service Enfance arrive sur son tableau de bord plutôt que sur l'écran
* de saisie : son travail commence par le contrôle, pas par la saisie. Le
* repli sur « planning » est indispensable, car la page « dashboard » n'est
* autorisée que si la migration V7.9 a été jouée — sans ce repli,
* requirePage() renverrait indéfiniment vers une page interdite.
*/
public function homeUrl(): string
{
return match ($this->role()) {
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
self::ROLE_SERVICE => 'planning.php',
self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php',
default => 'role.php',
};
}
@@ -248,10 +294,7 @@ final class Access
return;
}
$stmt = $this->pdo->prepare(
'SELECT p.id_structure
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
WHERE c.id_creneau = :id LIMIT 1'
'SELECT id_structure FROM v_acces_perimetre_creneau WHERE id_creneau = :id LIMIT 1'
);
$stmt->execute(['id' => $entryId]);
$structureId = $stmt->fetchColumn();
@@ -267,7 +310,9 @@ final class Access
if ($this->role() === self::ROLE_SERVICE) {
return;
}
$stmt = $this->pdo->prepare('SELECT id_structure FROM planning WHERE id_planning = :id LIMIT 1');
$stmt = $this->pdo->prepare(
'SELECT id_structure FROM v_acces_perimetre_planning WHERE id_planning = :id LIMIT 1'
);
$stmt->execute(['id' => $planningId]);
$structureId = $stmt->fetchColumn();
if ($structureId === false) {
@@ -293,14 +338,14 @@ final class Access
private function activeAgentExists(int $agentId): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE id_agent = :id AND actif = TRUE LIMIT 1');
$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;
}
private function activeStructureExists(int $structureId): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1');
$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;
}
@@ -312,11 +357,9 @@ final class Access
return null;
}
$stmt = $this->pdo->prepare(
'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
s.nom AS structure_nom
FROM agent a
LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1'
'SELECT id_agent, matricule, nom, prenom, id_structure, structure_nom
FROM v_acces_identite_agent
WHERE id_agent = :id LIMIT 1'
);
$stmt->execute(['id' => $agentId]);
return $stmt->fetch() ?: null;
@@ -330,8 +373,8 @@ final class Access
}
$stmt = $this->pdo->prepare(
'SELECT id_structure, code, nom
FROM structure
WHERE id_structure = :id AND actif = TRUE LIMIT 1'
FROM v_acces_identite_structure
WHERE id_structure = :id LIMIT 1'
);
$stmt->execute(['id' => $structureId]);
return $stmt->fetch() ?: null;
@@ -344,25 +387,11 @@ final class Access
return false;
}
$stmt = $this->pdo->prepare(
'SELECT 1
FROM agent a
WHERE a.id_agent = :agent_id
AND a.actif = TRUE
AND (
a.id_structure = :structure_id_default
OR EXISTS (
SELECT 1 FROM planning p
WHERE p.id_agent = a.id_agent
AND p.id_structure = :structure_id_planning
)
)
'SELECT 1 FROM v_acces_agent_par_structure
WHERE id_agent = :agent_id AND id_structure_autorisee = :structure_id
LIMIT 1'
);
$stmt->execute([
'agent_id' => $agentId,
'structure_id_default' => $structureId,
'structure_id_planning' => $structureId,
]);
$stmt->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
return $stmt->fetchColumn() !== false;
}
}

View File

@@ -8,9 +8,7 @@ final class Csrf
{
public static function ensureToken(): string
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
Session::start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

View File

@@ -21,18 +21,14 @@ final class JsonResponse
});
set_exception_handler(static function (\Throwable $exception): void {
error_log(sprintf(
'[PTA API] %s: %s in %s:%d',
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine()
));
// Security::reportThrowable journalise systématiquement le détail
// côté serveur et ne le renvoie au client que si PTA_DEBUG est
// explicitement activé. Par défaut, rien ne fuit.
$payload = ['error' => 'Une erreur serveur est survenue.'];
if ((getenv('PTA_DEBUG') ?: '1') !== '0') {
if (Security::debugEnabled()) {
$payload['details'] = $exception->getMessage();
}
Security::reportThrowable($exception, 'Erreur API.');
self::send(500, $payload);
});
@@ -42,6 +38,8 @@ final class JsonResponse
{
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
}
http_response_code($status);

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Classement métier des motifs de planning, partagé par les modèles et les PDF.
*
* Le référentiel `motif_planning` distingue le temps de travail effectif (ALP matin,
* restauration, ALP soir, mercredi périscolaire, extrascolaire, préparations, ménage de fond…)
* du temps décompté mais non travaillé (formation, absence) et du temps hors quota
* (congé, autre absence, JNT). Tous ces motifs de travail doivent être présentés
* comme des heures travaillées, et pas seulement le motif générique `TRAVAIL`.
*/
final class MotifCategories
{
public const TRAVAIL = 'TRAVAIL';
public const FORMATION = 'FORMATION';
public const CONGE = 'CONGE';
public const ABSENCE = 'ABSENCE';
public const NON_DECOMPTE = 'NON_DECOMPTE';
/** Motifs comptant comme du temps de travail effectif. */
public const WORK_CODES = [
'TRAVAIL',
'ALP_MATIN',
'RESTAURATION',
'ALP_SOIR',
'MERCREDI_PERISCOLAIRE',
'EXTRASCOLAIRE',
'PREPARATION_PERISCOLAIRE',
'PREPARATION_EXTRASCOLAIRE',
'PREPARATION_LUNDI',
'MENAGE_FOND',
'JOUR_FRACTIONNEMENT',
];
/** Motifs décomptés du quota sans être du temps de travail effectif. */
private const FORMATION_CODES = ['FORMATION'];
private const ABSENCE_CODES = ['ABSENCE', 'MALADIE'];
private const CONGE_CODES = ['CONGE'];
private const NON_DECOMPTE_CODES = ['AUTRE_ABSENCE', 'JNT'];
public static function normalizeCode(?string $code): string
{
return strtoupper(trim((string) $code));
}
/**
* Motif dont l'affectation dépend d'un droit porté par l'agent : la formation
* n'est ouverte qu'aux agents bénéficiant de l'enveloppe annuelle de 14 h
* (colonne `droit_enveloppe_formation`, voir AgentModel::hasTrainingRight()).
*/
public static function requiresTrainingRight(?string $code): bool
{
return self::normalizeCode($code) === self::FORMATION;
}
/**
* Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il
* décompte le quota : c'est le comportement le moins surprenant pour un motif ajouté
* en base après coup, et il évite qu'un total disparaisse des documents.
*/
public static function isWork(?string $code, bool $countsInQuota = false): bool
{
return self::category($code, $countsInQuota) === self::TRAVAIL;
}
/**
* @return self::TRAVAIL|self::FORMATION|self::CONGE|self::ABSENCE|self::NON_DECOMPTE
*/
public static function category(?string $code, bool $countsInQuota = false): string
{
$normalized = self::normalizeCode($code);
if (in_array($normalized, self::WORK_CODES, true)) {
return self::TRAVAIL;
}
if (in_array($normalized, self::FORMATION_CODES, true)) {
return self::FORMATION;
}
if (in_array($normalized, self::ABSENCE_CODES, true)) {
return self::ABSENCE;
}
if (in_array($normalized, self::CONGE_CODES, true)) {
return self::CONGE;
}
if (in_array($normalized, self::NON_DECOMPTE_CODES, true)) {
return self::NON_DECOMPTE;
}
return $countsInQuota ? self::TRAVAIL : self::NON_DECOMPTE;
}
}

89
app/Core/Security.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* En-têtes de sécurité des réponses HTML.
*
* La CSP est nominative : aucun script inline n'est autorisé sans le nonce
* généré à chaque requête. PTA ne contient aucun gestionnaire d'événement
* inline (onclick=, onchange=…) ni style inline, la politique peut donc rester
* stricte sans dégrader l'interface.
*/
final class Security
{
private static ?string $nonce = null;
public static function nonce(): string
{
if (self::$nonce === null) {
self::$nonce = base64_encode(random_bytes(16));
}
return self::$nonce;
}
public static function sendHtmlHeaders(): void
{
if (headers_sent()) {
return;
}
$nonce = self::nonce();
$policy = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"img-src 'self' data:",
"style-src 'self'",
"font-src 'self'",
// Aucune requête sortante depuis le navigateur : les appels à
// data.education.gouv.fr sont faits côté serveur.
"connect-src 'self'",
sprintf("script-src 'self' 'nonce-%s'", $nonce),
];
header('Content-Security-Policy: ' . implode('; ', $policy));
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');
header('Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()');
header('Cross-Origin-Opener-Policy: same-origin');
if (Session::isHttps()) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
/**
* Vrai uniquement si le mode debug a été activé explicitement.
* Par défaut, aucun détail technique ne sort du serveur.
*/
public static function debugEnabled(): bool
{
return in_array(strtolower((string) (getenv('PTA_DEBUG') ?: '0')), ['1', 'true', 'on', 'yes'], true);
}
/**
* Journalise l'exception côté serveur et renvoie le message à afficher.
* Le détail technique n'est rendu que si PTA_DEBUG est explicitement actif.
*/
public static function reportThrowable(\Throwable $exception, string $publicMessage): string
{
error_log(sprintf(
'[PTA] %s: %s in %s:%d',
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine()
));
return self::debugEnabled()
? $publicMessage . ' Détail : ' . $exception->getMessage()
: $publicMessage;
}
}

59
app/Core/Session.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Démarrage unique et durci de la session PHP.
*
* Les mêmes garanties sont posées ici et dans docker/php/php.ini : le php.ini
* ne s'applique qu'au conteneur fourni, alors que ce code protège aussi les
* déploiements sur un serveur PHP existant dont la configuration est inconnue.
*/
final class Session
{
private static bool $started = false;
public static function start(): void
{
if (self::$started || session_status() === PHP_SESSION_ACTIVE) {
self::$started = true;
return;
}
if (!headers_sent()) {
ini_set('session.use_only_cookies', '1');
ini_set('session.use_strict_mode', '1');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => self::isHttps(),
'httponly' => true,
'samesite' => 'Lax',
]);
}
session_start();
self::$started = true;
}
/**
* Vrai si la requête courante arrive en HTTPS, directement ou via un proxy
* de confiance. Sans cette détection, le cookie de session serait marqué
* « secure » sur une installation HTTP et la session ne fonctionnerait plus.
*/
public static function isHttps(): bool
{
if (($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
return true;
}
if ((int) ($_SERVER['SERVER_PORT'] ?? 0) === 443) {
return true;
}
return strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
}
}