Reintroduce TFE duration metadata: DB columns, form fields, controllers, views, and migration

Add 'unsafe-eval' to CSP script-src directives (htmx requires Function())
This commit is contained in:
Pontoporeia
2026-06-11 13:05:37 +02:00
parent 00fed5f0e3
commit d588ae004d
81 changed files with 1061 additions and 840 deletions

View File

@@ -7,8 +7,6 @@ require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/ShareLink.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
error_log('[acces-etudiante.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
App::adminGuard();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
@@ -90,6 +88,15 @@ switch ($action) {
}
break;
case 'delete':
if ($id > 0) {
$shareLink->delete($id);
App::redirect('/admin/acces.php', success: 'Lien supprimé définitivement.');
} else {
App::redirect('/admin/acces.php', error: 'Lien introuvable.');
}
break;
case 'update':
if ($id > 0) {
$name = isset($_POST['name']) ? trim($_POST['name']) : null;

View File

@@ -6,7 +6,6 @@
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[access-request.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | request_id=' . ($_POST['request_id'] ?? 0) . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])

View File

@@ -11,8 +11,6 @@ require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
require_once __DIR__ . '/../../../src/Database.php';
error_log('[account.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'change_password') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
// ── CSRF ──────────────────────────────────────────────────────────────────────

View File

@@ -8,7 +8,6 @@
*/
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[apropos.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['apropos_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))

View File

@@ -1,8 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[corbeille.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none'));
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -1,8 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[delete.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | is_bulk=' . (!empty($_POST['bulk']) ? '1' : '0') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';

View File

@@ -0,0 +1,93 @@
<?php
/**
* Admin autosave draft endpoint.
*
* POST — receive all form fields and persist them to the session draft store.
*
* Drafts are scoped per mode:
* - add: keyed by a generated token (stored in form)
* - edit: keyed by thesis_id
*
* Excluded field patterns (not persisted as drafts):
* - csrf_token
* - FilePond metadata (filepond_mode, queue_file, filepond_*)
* - Files-related fields
* - Empty values
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once APP_ROOT . '/src/AdminAuth.php';
AdminAuth::requireLogin();
$method = $_SERVER['REQUEST_METHOD'];
// ── CSRF check ──────────────────────────────────────────────────────────
if ($method !== 'POST') {
http_response_code(405);
header('Content-Type: application/json');
echo json_encode(['error' => 'Méthode non autorisée.']);
exit;
}
if (
!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Token de sécurité invalide.']);
exit;
}
// ── Determine draft key ─────────────────────────────────────────────────
$draftToken = $_POST['draft_token'] ?? '';
$thesisId = (int)($_POST['thesis_id'] ?? 0);
if ($draftToken !== '' && preg_match('/^[a-f0-9]{16}$/', $draftToken)) {
$draftKey = 'admin_draft_' . $draftToken;
} elseif ($thesisId > 0) {
$draftKey = 'admin_draft_edit_' . $thesisId;
} else {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['error' => 'Paramètres invalides.']);
exit;
}
// ── Save all form fields ────────────────────────────────────────────────
$excludePrefixes = [
'csrf_token', 'share_link_token',
'filepond_mode', 'queue_file', 'filepond_',
];
$excludeExact = ['draft_token', 'thesis_id', 'slug',
'couverture', 'note_intention', 'files', 'annexes',
'peertube_video', 'peertube_audio', 'cover_remove',
'go', 'MAX_FILE_SIZE'];
$draft = [];
foreach ($_POST as $key => $value) {
if (in_array($key, $excludeExact, true)) continue;
$skip = false;
foreach ($excludePrefixes as $prefix) {
if (str_starts_with($key, $prefix)) { $skip = true; break; }
}
if ($skip) continue;
if ($value === '' || $value === null || (is_array($value) && count($value) === 0)) {
continue;
}
$draft[$key] = $value;
}
$_SESSION[$draftKey] = $draft;
// Rotate CSRF after mutation
$newToken = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $newToken;
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'csrf_token' => $newToken,
]);
exit;

View File

@@ -3,8 +3,6 @@
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[edit.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | content_length=' . ($_SERVER['CONTENT_LENGTH'] ?? '0') . ' | post_keys=' . implode(',', array_keys($_POST)) . ' | files_count=' . count($_FILES) . ' | files_keys=' . implode(',', array_keys($_FILES)) . ' | queue_tfe=' . (isset($_FILES['queue_file']['name']['tfe']) ? count(array_filter($_FILES['queue_file']['name']['tfe'])) : 0));
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
@@ -37,6 +35,10 @@ try {
// Regenerate CSRF token after successful save
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// Clear autosave draft + FilePond temp files
unset($_SESSION['admin_draft_edit_' . $thesisId]);
unset($_SESSION['filepond_tmp']);
AdminLogger::make()->logEdit($thesisId, $_POST['titre'] ?? $_POST['title'] ?? '');
App::flash('success', "TFE mis à jour avec succès!");

View File

@@ -24,7 +24,6 @@ function relinkError(int $code, string $message): never {
// CSRF via header
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
error_log('[relink] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | csrf=' . (isset($_SESSION['csrf_token']) ? 'set' : 'missing') . ' | header=' . (strlen($csrfHeader) > 0 ? substr($csrfHeader, 0, 8) . '...' : 'empty') . ' | body_len=' . strlen(file_get_contents('php://input')));
if (!isset($_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $csrfHeader)) {
relinkError(403, 'Token CSRF invalide.');

View File

@@ -6,7 +6,6 @@
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[form-help.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['form_help_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))

View File

@@ -3,8 +3,6 @@
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[formulaire.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | content_length=' . ($_SERVER['CONTENT_LENGTH'] ?? '0') . ' | post_keys=' . implode(',', array_keys($_POST)) . ' | files_count=' . count($_FILES) . ' | files_keys=' . implode(',', array_keys($_FILES)) . ' | queue_tfe=' . (isset($_FILES['queue_file']['name']['tfe']) ? count(array_filter($_FILES['queue_file']['name']['tfe'])) : 0));
// Only suppress display_errors in production (cli-server = dev mode).
if (php_sapi_name() !== 'cli-server') {
ini_set('display_errors', 0);
@@ -25,8 +23,6 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
die('Erreur de sécurité : token invalide. Veuillez recharger le formulaire.');
}
error_log('[formulaire.php] full FILES array: ' . print_r($_FILES, true));
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
require_once APP_ROOT . '/src/AppLogger.php';
require_once APP_ROOT . '/src/AdminLogger.php';
@@ -45,6 +41,10 @@ try {
$logger->logSubmission('admin', $thesisId, $identifier, $authorName);
$adminLogger->logAdd($thesisId, $identifier, $authorName);
// Clear autosave draft + FilePond temp files
unset($_SESSION['admin_draft_' . ($_POST['draft_token'] ?? '')]);
unset($_SESSION['admin_draft_add_token']);
unset($_SESSION['filepond_tmp']);
unset($_SESSION['csrf_token']);
$redirect = '../recapitulatif.php?id=' . $thesisId;

View File

@@ -1,7 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[language.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -1,7 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[maintenance.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -5,7 +5,6 @@
*/
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[page.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | slug=' . ($_POST['slug'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))

View File

@@ -1,8 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[publish.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | is_bulk=' . (!empty($_POST['bulk']) ? '1' : '0') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';

View File

@@ -1,12 +1,10 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[settings.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | section=' . ($_POST['section'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log('[settings.php] CSRF FAIL | session_token=' . ($_SESSION['csrf_token'] ?? 'none') . ' | post_token=' . ($_POST['csrf_token'] ?? 'none'));
App::flash('error', "Erreur de sécurité : token invalide.");
header('Location: /admin/parametres.php');
exit;
@@ -21,7 +19,6 @@ $logger = AdminLogger::make();
$isHxRequest = (isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'] === 'true');
$section = $_POST['section'] ?? '';
error_log('[settings.php] PROCESS | section=' . $section . ' | post_keys=' . implode(',', array_keys($_POST)));
/**
* Return an HTML toast fragment for HTMX responses and exit.
@@ -58,9 +55,7 @@ function hxToastError(string $message): never {
if ($section === 'formulaire_restrictions') {
// HTMX may not send unchecked checkboxes even with hidden 0-value inputs;
// missing key means unchecked → treat as '0'.
$rawPost = $_POST['restricted_files_enabled'] ?? '(missing)';
$newValue = empty($_POST['restricted_files_enabled']) ? '0' : '1';
error_log('[settings.php] SAVE formulaire_restrictions | restricted_files_enabled raw=' . var_export($rawPost, true) . ' | resolved=' . $newValue);
$db->setSetting('restricted_files_enabled', $newValue);
$logger->logFormSettingsUpdate(['restricted_files_enabled' => $newValue]);
if ($isHxRequest) {
@@ -76,9 +71,7 @@ if ($section === 'formulaire_restrictions') {
];
$newValues = [];
foreach ($allowed as $key) {
$raw = $_POST[$key] ?? '(missing)';
$value = empty($_POST[$key]) ? '0' : '1';
error_log('[settings.php] SAVE formulaire_acces | ' . $key . ' raw=' . var_export($raw, true) . ' | resolved=' . $value);
$db->setSetting($key, $value);
$newValues[$key] = $value;
}
@@ -89,14 +82,10 @@ if ($section === 'formulaire_restrictions') {
App::flash('success', "Degrés d'ouverture mis à jour.");
}
} elseif ($section === 'objet_types') {
$rawThese = $_POST['objet_these_enabled'] ?? '(missing)';
$rawFrart = $_POST['objet_frart_enabled'] ?? '(missing)';
$newValues = [
'objet_these_enabled' => empty($_POST['objet_these_enabled']) ? '0' : '1',
'objet_frart_enabled' => empty($_POST['objet_frart_enabled']) ? '0' : '1',
];
error_log('[settings.php] SAVE objet_types | objet_these_enabled raw=' . var_export($rawThese, true) . ' | resolved=' . $newValues['objet_these_enabled']);
error_log('[settings.php] SAVE objet_types | objet_frart_enabled raw=' . var_export($rawFrart, true) . ' | resolved=' . $newValues['objet_frart_enabled']);
$db->setSetting('objet_these_enabled', $newValues['objet_these_enabled']);
$db->setSetting('objet_frart_enabled', $newValues['objet_frart_enabled']);
$logger->logObjetTypesUpdate($newValues);

View File

@@ -1,7 +1,6 @@
<?php
require_once __DIR__ . "/../../../bootstrap.php";
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[smtp-test.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | test_email=' . ($_POST['test_email'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
require_once APP_ROOT . '/src/Database.php';

View File

@@ -1,7 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[tag.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'

View File

@@ -1,7 +1,6 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
error_log('[visibility.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none') . ' | is_bulk=' . (!empty($_POST['bulk']) ? '1' : '0') . ' | post_keys=' . implode(',', array_keys($_POST)));
AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])

View File

@@ -83,35 +83,33 @@ $extraJsInline = '';
if ($editType === 'page' || $editType === 'about_page') {
$initialContent = $page["content"] ?? "";
$extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave-handler.js"];
$extraJs = ["/assets/js/vendor/overtype.min.js"];
$extraJsInline = <<<'JS'
var OT = window.OverType.default || window.OverType;
var hidden = document.getElementById('content');
var editor = new OT(document.getElementById('editor'), {
value: hidden.value,
minHeight: '400px',
minHeight: '100%',
spellcheck: false,
toolbar: true,
onChange: function(value) {
hidden.value = value;
hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true }));
}
});
JS;
} elseif ($editType === 'form_help') {
$initialContent = $formHelpContent;
$extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave-handler.js"];
$extraJs = ["/assets/js/vendor/overtype.min.js"];
$extraJsInline = <<<'JS'
var OT = window.OverType.default || window.OverType;
var hidden = document.getElementById('content');
var editor = new OT(document.getElementById('editor'), {
value: hidden.value,
minHeight: '400px',
minHeight: '100%',
spellcheck: false,
toolbar: true,
onChange: function(value) {
hidden.value = value;
hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true }));
}
});
JS;

View File

@@ -123,12 +123,12 @@ extract(FormBootstrap::adminFormVariables(
mode: 'edit',
formAction: '/admin/actions/edit.php',
hiddenFields:
'<input type="hidden" name="csrf_token" value="' . htmlspecialchars($_SESSION['csrf_token']) . '">'
. '<input type="hidden" name="thesis_id" value="' . $thesisId . '">',
'<input type="hidden" name="csrf_token" value="' . htmlspecialchars($_SESSION['csrf_token']) . '">',
formData: $formData,
siteSettings: $siteSettings,
helpBlocks: $helpBlocks,
options: [
'thesisId' => $thesisId,
'filesMode' => 'edit',
'existingWebsiteUrl' => $existingWebsiteUrl,
'existingWebsiteLabel' => $existingWebsiteLabel,
@@ -144,6 +144,8 @@ extract(FormBootstrap::adminFormVariables(
'currentFiles' => $currentFiles ?? [],
'currentContextNote' => $currentContextNote ?? null,
'currentContactVisible' => $currentContactVisible ?? null,
'currentDurationValue' => $currentDurationValue ?? null,
'currentDurationUnit' => $currentDurationUnit ?? 'pages',
'contactInterne' => $contactInterne ?? null,
'contactPublic' => $contactPublic ?? false,
'showCoverPreview' => true,
@@ -158,6 +160,10 @@ $formData['license_id'] = $currentLicenseId;
$formData['license_custom'] = $currentRaw['license_custom'] ?? '';
$formData['cc2r'] = $currentRaw['cc2r'] ?? false;
// Duration variables for the form template
$durationValue = $currentDurationValue ?? null;
$durationUnit = $currentDurationUnit ?? 'pages';
// Asset arrays and page chrome
$isAdmin = true;
$bodyClass = 'admin-body';

View File

@@ -13,8 +13,6 @@ AdminAuth::requireLogin();
$storageRoot = STORAGE_ROOT;
error_log('[file-browser] ENTRY | dir=' . ($_GET['dir'] ?? '(root)') . ' | storageRoot=' . $storageRoot);
// Determine which directory to browse
$relDir = trim($_GET['dir'] ?? '', '/');
if ($relDir !== '' && !preg_match('#^(tfe|these|frart|documents|theses)(/|$)#', $relDir)) {

View File

@@ -17,7 +17,6 @@ $importResults = [];
$importDone = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
error_log('[admin/index.php] IMPORT ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | content_type=' . ($_SERVER['CONTENT_TYPE'] ?? 'none') . ' | csv_file_error=' . ($_FILES['csv_file']['error'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
$importErrors[] = "Erreur de sécurité : token invalide.";
} else {

View File

@@ -2,8 +2,6 @@
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
error_log('[admin/login.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | is_auth=' . (AdminAuth::isAuthenticated() ? '1' : '0') . ' | has_password=' . (AdminAuth::hasPassword() ? '1' : '0'));
if (!AdminAuth::hasPassword()) {
header('Location: /admin/');
exit;
@@ -24,8 +22,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
$pageTitle = 'Connexion';
$isAdmin = true; $bodyClass = 'admin-body';
$isAdmin = true; $isLogin = true; $bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php';
include APP_ROOT . '/templates/admin/login.php';
require_once APP_ROOT . '/templates/admin/footer.php';
// Login page does not render the admin footer (no toast-region poll, no HTMX extras).
// It closes <html> directly so there is no dangling HTMX polling the toast endpoint
// while unauthenticated.
echo "\n</body>\n</html>";

View File

@@ -24,7 +24,10 @@ if (isset($_GET['id'])) {
if ($thesisId !== false && $thesisId > 0) {
try {
$db = new Database();
$thesis = $db->getThesis($thesisId);
// Student-mode preview: only show published theses.
$thesis = $studentMode
? $db->getThesisById($thesisId)
: $db->getThesis($thesisId);
if (!$thesis) {
$error = "TFE non trouvé.";

View File

@@ -9,7 +9,13 @@
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
// Don't redirect unauthenticated requests — just return empty (defense-in-depth).
// The toast-region poll fires on <hx-trigger="load">; if the user is on the
// login page they are not authenticated yet.
if (!AdminAuth::isAuthenticated()) {
http_response_code(204);
exit;
}
$flash = App::consumeFlash();

View File

@@ -541,7 +541,7 @@ th.admin-ap-col {
.admin-body main > section[aria-labelledby^="settings-"] fieldset legend {
padding: 0;
font-weight: 600;
font-weight: 400;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
@@ -1303,7 +1303,7 @@ th.admin-ap-col {
flex: 1 1 260px;
}
.param-smtp-test-row label {
font-weight: 600;
font-weight: 400;
margin-bottom: var(--space-2xs);
color: var(--text-secondary);
}
@@ -1909,7 +1909,7 @@ th.admin-ap-col {
.fhb-edit-label {
display: block;
font-size: var(--step--2);
font-weight: 500;
font-weight: 400;
color: var(--text-primary);
margin-bottom: var(--space-3xs);
}

View File

@@ -13,23 +13,32 @@ details > summary::-webkit-details-marker {
}
details {
padding: var(--space-s);
border: 1px solid var(--border-color);
border-radius: var(--radius, 6px);
background: var(--bg-secondary, var(--surface));
overflow: hidden;
}
details[open] {
padding-bottom: var(--space-s);
}
details > :not(summary) {
margin-left: var(--space-s);
margin-right: var(--space-s);
}
summary {
font-family: var(--font-display);
font-weight: 600;
text-decoration: none;
color: var(--accent-secondary);
transition: color 0.15s;
svg {
fill: var(--accent-secondary);
vertical-align: text-bottom;
height: 1.4em;
}
color: var(--text-primary);
transition: color 0.15s, background 0.15s;
padding: var(--space-s);
cursor: pointer;
}
summary:hover {
color: var(--accent-primary);
background: var(--hover-bg, rgba(0, 0, 0, 0.03));
}

View File

@@ -9,6 +9,7 @@
label {
display: block;
margin-bottom: var(--space-3xs);
font-weight: 400;
}
/* ── Text inputs, selects, textareas ────────────────────────────────── */
@@ -95,7 +96,7 @@ fieldset > *:not(:last-child) { margin-bottom: var(--space-xs); }
legend {
font-size: var(--step--1);
font-weight: 600;
font-weight: 400;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);

View File

@@ -242,6 +242,6 @@
.admin-dialog label {
display: block;
font-weight: 600;
font-weight: 400;
margin-bottom: var(--space-3xs);
}

View File

@@ -232,3 +232,68 @@
.file-browser-file .file-browser-select-btn:hover {
background: var(--bg-secondary);
}
/* ── Full-page editor (contenus-edit overtype) ─────────────────────── */
/* Make the main content area a flex column so the form fills remaining height */
.admin-body .full-editor-page {
display: flex;
flex-direction: column;
}
.admin-form--full-editor {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.admin-form--full-editor #editor {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.admin-form--full-editor #editor .n-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.admin-form--full-editor #editor .n-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.admin-form--full-editor #editor .n-input {
height: 100% !important;
overflow-y: auto !important;
}
.admin-form--full-editor #editor .n-preview {
height: 100% !important;
overflow-y: auto !important;
}
.full-editor-toolbar {
display: flex;
align-items: center;
gap: var(--space-s);
padding-bottom: var(--space-s);
border-bottom: 1px solid var(--border-primary);
margin-bottom: var(--space-s);
flex-shrink: 0;
}
.full-editor-label {
font-size: var(--step--1);
font-weight: 600;
color: var(--text-secondary);
}
.full-editor-toolbar .btn--primary {
margin-left: auto;
}

View File

@@ -68,7 +68,7 @@
> label,
.admin-form div:has(select:required) > label,
.admin-form div:has(textarea:required) > label {
font-weight: 600;
font-weight: 400;
}
/* Asterisk on required field labels */
@@ -161,6 +161,19 @@
gap: var(--space-3xs);
}
.admin-form-group--inline {
flex-direction: row;
align-items: end;
gap: var(--space-xs);
flex-wrap: wrap;
}
.admin-form-group--inline > div {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
/* ── Jury fieldset ──────────────────────────────────────────────────────── */
/* ── Website-URL inline fieldset (shown/hidden via HTMX) ────────────────── */
@@ -178,7 +191,7 @@
.admin-body fieldset fieldset.admin-jury-lecteurs > legend,
.student-body fieldset fieldset.admin-jury-lecteurs > legend {
font-size: var(--step--2);
font-weight: 600;
font-weight: 400;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--text-tertiary);
@@ -845,7 +858,7 @@
.retry-email-form label {
font-size: var(--step--1);
font-weight: 600;
font-weight: 400;
}
.retry-email-form input[type="email"] {
@@ -1207,7 +1220,7 @@ legend {
}
.licence-options-fieldset legend {
font-weight: 600;
font-weight: 400;
font-size: var(--step--1);
color: var(--text-secondary);
padding: 0 var(--space-2xs);
@@ -1227,7 +1240,7 @@ legend {
.admin-form > div:not(.admin-form-footer) > label,
.admin-form > div:not(.admin-form-footer) > span.admin-row-label {
padding-top: 0;
font-weight: 500;
font-weight: 400;
}
/* Labels inside fieldsets (checkbox/radio groups) */

View File

@@ -11,16 +11,16 @@
}
/* ---- 6-column index layout ---- */
/* Column fractions: years=0.4 ap=1 or=1.2 fi=0.7 students=1 kw=1 */
/* Equal-width columns except Années (years) = narrower */
.repertoire-index {
display: grid;
grid-template-columns:
minmax(3rem, 0.4fr)
minmax(12rem, 1.4fr)
minmax(9rem, 0.8fr)
minmax(7rem, 0.8fr)
minmax(8rem, 0.7fr)
minmax(7rem, 1fr);
minmax(3rem, 0.45fr)
minmax(12rem, 1fr)
minmax(9rem, 1fr)
minmax(7rem, 1fr)
minmax(8rem, 1fr)
minmax(min-content, 1fr);
grid-template-rows: auto 1fr;
gap: var(--space-s);
justify-content: space-between;
@@ -78,14 +78,14 @@
.repertoire-col > h2 {
grid-row: 1;
font-family: var(--font-display);
font-size: var(--step-0);
font-size: var(--step--1);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
color: var(--text-primary);
font-weight: 398;
margin: 0;
padding: var(--space-xs) var(--space-2xs) var(--space-3xs);
border-bottom: 1px solid var(--border-secondary);
padding: var(--space-xs) 0 var(--space-3xs) 0;
border-bottom: 1px solid var(--text-primary);
align-self: end;
hyphens: manual;
word-break: normal;
@@ -96,7 +96,7 @@
grid-row: 2;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-2xs) var(--space-2xs) var(--space-l);
padding: var(--space-2xs) 0 var(--space-l) 0;
}
/* Strip list chrome inside repertoire columns */

View File

@@ -255,7 +255,7 @@
.tfe-access-request-form label {
font-size: var(--step--1);
font-weight: 600;
font-weight: 400;
color: var(--text-primary);
}

View File

@@ -5,7 +5,14 @@
* parse errors instead of silently swallowing them (unlike the
* old autosave.js .catch(() => {}) pattern).
*/
function _handleAutosaveResponse(event) {
function handleAutosaveResponse(event) {
// Only handle responses from autosave endpoints (draft.php).
// The htmx:afterRequest event bubbles, so child elements'
// HTMX requests (e.g. licence fragment, pill-search) also
// reach this handler. We filter by URL to avoid mixing them.
const url = event.detail.requestConfig?.path || "";
if (!url.includes("draft.php")) return;
const form = event.target.closest("form");
const status = form ? form.querySelector("[data-autosave-status]") : null;
@@ -56,11 +63,17 @@ function _handleAutosaveResponse(event) {
// Show saving indicator while request is in flight
document.body.addEventListener("htmx:beforeRequest", (e) => {
const url = e.detail.requestConfig?.path || "";
if (!url.includes("draft.php")) return;
// The autosave request comes from the hidden probe div, so find
// the status indicator by searching the closest form.
const el = e.target;
if (!el) return;
const status = el.querySelector("[data-autosave-status]");
const form = el?.closest?.("form");
const status = form?.querySelector?.("[data-autosave-status]");
if (status) {
status.textContent = "Enregistrement…";
status.className = "autosave-status autosave-status--saving";
}
});

View File

@@ -277,7 +277,11 @@ function renderShareLinkForm(string $slug, array $link): void
// Filter out PACS from AP programs for student forms (spec: admin-only AP)
$apPrograms = array_values(array_filter($apPrograms, fn($ap) => ($ap['code'] ?? '') !== 'PACS'));
$formData = $_SESSION['form_data_share_' . $slug] ?? [];
// Hydrate form data from session draft (autosave). Flash repopulation
// (from validation redirects) takes priority over stale draft entries.
$draftKey = 'partage_draft_' . $slug;
$draftData = $_SESSION[$draftKey] ?? [];
$formData = array_merge($draftData, $_SESSION['form_data_share_' . $slug] ?? []);
unset($_SESSION['form_data_share_' . $slug]);
// Determine allowed objet values for this link
@@ -324,7 +328,8 @@ function renderShareLinkForm(string $slug, array $link): void
// ── Shared form variables ──────────────────────────────────────────────
$mode = 'partage';
$formAction = '/partage/' . urlencode($slug) . '/submit';
$hiddenFields = '<input type="hidden" name="share_link_token" value="' . htmlspecialchars($shareCsrfToken) . '">';
$hiddenFields = '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($csrfToken) . '">'
. '<input type="hidden" name="share_link_token" value="' . htmlspecialchars($shareCsrfToken) . '">';
$oldFn = $shareOldFn;
$withAutofocusFn = $shareWithAutofocusFn;
@@ -399,6 +404,11 @@ function renderShareLinkForm(string $slug, array $link): void
$currentContextNote = null;
$currentContactVisible = null;
// ── Autosave wiring ─────────────────────────────────────────────────┐
$autosaveUrl = '/partage/fragments/draft.php?slug=' . urlencode($slug);
$formExtraAttrs = '';
$showAutosaveStatus = true;
include APP_ROOT . '/templates/partage/form-page.php';
?>
<main id="main-content">
@@ -537,6 +547,8 @@ function handleShareLinkSubmission(string $slug): void
unset($_SESSION['share_verified_' . $slug]);
unset($_SESSION['share_active']);
unset($_SESSION['share_primed_files_' . $slug]);
// Clear autosave draft — submission succeeded
unset($_SESSION['partage_draft_' . $slug]);
// Clear FilePond temp file tracking — files have been moved to permanent storage
unset($_SESSION['filepond_tmp']);

View File

@@ -18,7 +18,8 @@ if ($thesisId <= 0) {
}
$db = Database::getInstance();
$thesis = $db->getThesis($thesisId);
// Only published theses are visible via public recap (no slug-auth here).
$thesis = $db->getThesisById($thesisId);
if (!$thesis) {
http_response_code(404);
die('TFE introuvable.');