Base de travail V7.8 reprise du répertoire de travail
Commit technique : reproduit à l'identique l'état non commité de la copie de travail (sécurisation V7.8, CSP à nonce, Session, migration des droits) afin que le travail d'ergonomie qui suit soit isolable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ use App\Core\Csrf;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\PdfResponse;
|
||||
use App\Core\Request;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\QuotaModel;
|
||||
use App\Models\StructureModel;
|
||||
@@ -410,7 +411,7 @@ final class AgentController
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Impossible de créer l’agent.',
|
||||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
||||
'details' => Security::debugEnabled() ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Controllers;
|
||||
use App\Core\Access;
|
||||
use App\Core\Controller;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\MotifModel;
|
||||
use App\Models\StructureModel;
|
||||
@@ -177,10 +178,12 @@ final class PageController extends Controller
|
||||
|
||||
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
|
||||
{
|
||||
Security::sendHtmlHeaders();
|
||||
$profile = $this->access->profile();
|
||||
$this->render('pages/' . $view, array_merge([
|
||||
'pageTitle' => $title,
|
||||
'activePage' => $activePage,
|
||||
'cspNonce' => Security::nonce(),
|
||||
'csrfToken' => Csrf::ensureToken(),
|
||||
'scripts' => [],
|
||||
'modules' => [],
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Controllers;
|
||||
use App\Core\Access;
|
||||
use App\Core\Controller;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\StructureModel;
|
||||
use PDO;
|
||||
@@ -21,8 +22,13 @@ final class RoleController extends Controller
|
||||
{
|
||||
$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') {
|
||||
$access->clear();
|
||||
$provided = (string) ($_GET['csrf_token'] ?? '');
|
||||
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
|
||||
$access->clear();
|
||||
}
|
||||
header('Location: role.php', true, 302);
|
||||
exit;
|
||||
}
|
||||
@@ -47,8 +53,10 @@ final class RoleController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
Security::sendHtmlHeaders();
|
||||
$this->render('pages/role', [
|
||||
'pageTitle' => 'Choisir votre accès',
|
||||
'cspNonce' => Security::nonce(),
|
||||
'csrfToken' => Csrf::ensureToken(),
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
|
||||
@@ -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
|
||||
@@ -248,10 +285,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 +301,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 +329,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 +348,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 +364,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 +378,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
89
app/Core/Security.php
Normal file
89
app/Core/Security.php
Normal 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
59
app/Core/Session.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -78,5 +78,43 @@ final class SchemaModel extends BaseModel
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertAccessViews();
|
||||
}
|
||||
|
||||
/**
|
||||
* Les vues d'accès portent l'intégralité des règles de droits. Si elles
|
||||
* manquent, l'application doit refuser de démarrer plutôt que de tourner
|
||||
* sans contrôle de périmètre.
|
||||
*/
|
||||
private function assertAccessViews(): void
|
||||
{
|
||||
$requiredViews = [
|
||||
'v_acces_role_page',
|
||||
'v_acces_role_permission',
|
||||
'v_acces_identite_agent',
|
||||
'v_acces_identite_structure',
|
||||
'v_acces_agent_par_structure',
|
||||
'v_acces_perimetre_creneau',
|
||||
'v_acces_perimetre_planning',
|
||||
];
|
||||
|
||||
$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(
|
||||
'Contrôle d’accès indisponible : vue(s) manquante(s) : '
|
||||
. implode(', ', $missing)
|
||||
. '. Exécutez database/migration_v7_8_securite_acces.sql.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
/** @var array $pageConfig */
|
||||
/** @var array $accessProfile */
|
||||
/** @var array $permissions */
|
||||
/** @var string $cspNonce */
|
||||
?>
|
||||
</main>
|
||||
<script>
|
||||
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
|
||||
window.PTA_CONFIG = {
|
||||
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
|
||||
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
|
||||
@@ -27,10 +28,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.7"></script>
|
||||
<script src="assets/js/core.js?v=7.8"></script>
|
||||
<?php foreach ($scripts as $script): ?>
|
||||
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.7"></script>
|
||||
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.8"></script>
|
||||
<?php endforeach; ?>
|
||||
<script src="assets/js/page-bootstrap.js?v=7.7"></script>
|
||||
<script src="assets/js/page-bootstrap.js?v=7.8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
/** @var string $activePage */
|
||||
/** @var array $allowedPages */
|
||||
/** @var array $accessProfile */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$navigation = [
|
||||
'planning' => ['planning.php', 'Saisie'],
|
||||
@@ -23,7 +24,7 @@ $navigation = [
|
||||
<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.7">
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.8">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
@@ -44,7 +45,7 @@ $navigation = [
|
||||
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<a class="button button-ghost switch-role-link" href="role.php?action=reset">Changer de rôle</a>
|
||||
<a class="button button-ghost switch-role-link" href="role.php?action=reset&csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Changer de rôle</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
/** @var array $structures */
|
||||
/** @var string $error */
|
||||
/** @var array|null $currentProfile */
|
||||
/** @var string $cspNonce */
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
@@ -11,7 +12,7 @@
|
||||
<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.4">
|
||||
<link rel="stylesheet" href="assets/style.css?v=7.8">
|
||||
</head>
|
||||
<body class="role-page-body">
|
||||
<main class="role-page-shell">
|
||||
@@ -97,7 +98,7 @@
|
||||
<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é.
|
||||
</aside>
|
||||
</main>
|
||||
<script>
|
||||
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
|
||||
(() => {
|
||||
const agentContext = document.querySelector('#agent-context');
|
||||
const responsableContext = document.querySelector('#responsable-context');
|
||||
|
||||
@@ -10,7 +10,7 @@ $defaultCalendarYear = $currentYear;
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Vacances scolaires</h2>
|
||||
<p class="section-subtitle">Consultez toutes les vacances d’une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
|
||||
<p class="section-subtitle">Consultez toutes les vacances d'une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ $defaultCalendarYear = $currentYear;
|
||||
<div class="selection-grid vacation-filters">
|
||||
<label class="field">
|
||||
<span>Académie</span>
|
||||
<input id="vacation-academy" type="text" maxlength="100" value="Orléans-Tours" placeholder="Ex. Orléans-Tours" list="vacation-academies" autocomplete="off">
|
||||
<input id="vacation-academy" type="text" maxlength="100" value="Dijon" placeholder="Ex. Dijon" list="vacation-academies" autocomplete="off">
|
||||
<datalist id="vacation-academies">
|
||||
<option value="Aix-Marseille"><option value="Amiens"><option value="Besançon"><option value="Bordeaux">
|
||||
<option value="Clermont-Ferrand"><option value="Créteil"><option value="Dijon"><option value="Grenoble">
|
||||
@@ -45,8 +45,8 @@ $defaultCalendarYear = $currentYear;
|
||||
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
|
||||
<div class="section-heading compact-heading">
|
||||
<div>
|
||||
<h3>Dates proposées par l’API</h3>
|
||||
<p class="section-subtitle">Toutes les périodes de l’année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p>
|
||||
<h3>Dates proposées par l'API</h3>
|
||||
<p class="section-subtitle">Toutes les périodes de l'année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p>
|
||||
</div>
|
||||
<label class="vacation-select-all">
|
||||
<input id="vacation-select-all" type="checkbox">
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Session;
|
||||
use App\Models\SchemaModel;
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
Session::start();
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/db.php';
|
||||
(new SchemaModel($pdo))->assertCurrent();
|
||||
|
||||
Reference in New Issue
Block a user