fix: duplicate warning not shown in admin, double-encoded in partage, no focus

- toast-fragment.php: 204 early-exit now also checks flash['warning'];
  previously the warning was consumed by consumeFlash() then silently dropped
- partage/index.php: store warning as plain text; htmlspecialchars() applied
  once at render time — previously htmlspecialchars() was called inside the
  stored string then again at output, producing ' entities etc.
- partage/index.php: flash-warning div gets id + tabindex=-1; inline JS
  scrolls it into view and focuses it on DOMContentLoaded
- admin/footer.php: htmx:afterSettle listener focuses .toast--warning after
  HTMX injects the toast fragment into #toast-region
This commit is contained in:
Pontoporeia
2026-05-04 17:04:09 +02:00
parent a2cba6d3c0
commit 5f24dcae7e
8 changed files with 42 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
# XAMXAM TODO # XAMXAM TODO
## Duplicate TFE submission prevention ## Duplicate TFE submission prevention (fixes)
- [x] `DuplicateThesisException` — typed exception carrying existing thesis metadata - [x] `DuplicateThesisException` — typed exception carrying existing thesis metadata
- [x] `Database::findDuplicateThesis()` — year + author + normalised-title matching (exact, prefix, Levenshtein ≤10%) - [x] `Database::findDuplicateThesis()` — year + author + normalised-title matching (exact, prefix, Levenshtein ≤10%)
- [x] `ThesisCreateController::submit()` — calls duplicate check before any DB write, throws `DuplicateThesisException` - [x] `ThesisCreateController::submit()` — calls duplicate check before any DB write, throws `DuplicateThesisException`
@@ -11,3 +11,9 @@
- [x] `toast.php` — renders `toast--warning` block - [x] `toast.php` — renders `toast--warning` block
- [x] `admin.css``.toast--warning` style + link colour - [x] `admin.css``.toast--warning` style + link colour
- [x] `form.css``.flash-warning` style (partage form) - [x] `form.css``.flash-warning` style (partage form)
## Duplicate warning display fixes
- [x] `toast-fragment.php` — 204 guard now also checks `warning`; warning was silently discarded before
- [x] `partage/index.php` — warning stored as plain text (no pre-escaping); `htmlspecialchars()` applied once at render; was double-encoded before
- [x] `partage/index.php``flash-warning` div gets `id` + `tabindex=-1`; inline JS scrolls and focuses it on load
- [x] `admin/footer.php``htmx:afterSettle` listener focuses `.toast--warning` after HTMX injects the toast fragment

View File

@@ -13,7 +13,7 @@ AdminAuth::requireLogin();
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
error_log(sprintf( error_log(sprintf(
'CSRF token validation failed in formulaire.php POST token: %s, SESSION token: %s', 'CSRF token validation failed in formulaire.php - POST token: %s, SESSION token: %s',
$_POST['csrf_token'] ?? '(missing)', $_POST['csrf_token'] ?? '(missing)',
$_SESSION['csrf_token'] ?? '(missing)' $_SESSION['csrf_token'] ?? '(missing)'
)); ));
@@ -49,10 +49,10 @@ try {
// Build a warning with a clickable link to the existing thesis. // Build a warning with a clickable link to the existing thesis.
$existingUrl = htmlspecialchars('/admin/edit.php?id=' . $e->existingThesisId); $existingUrl = htmlspecialchars('/admin/edit.php?id=' . $e->existingThesisId);
$existingRef = htmlspecialchars($e->existingIdentifier . ' ' . $e->existingTitle . ' (' . $e->existingYear . ')'); $existingRef = htmlspecialchars($e->existingIdentifier . ' - ' . $e->existingTitle . ' (' . $e->existingYear . ')');
$warningHtml = 'Doublon détecté : un TFE très similaire existe déjà.' $warningHtml = 'Doublon détecté : un TFE très similaire existe déjà.'
. '<a href="' . $existingUrl . '" style="color:inherit;text-decoration:underline">' . $existingRef . '</a>' . '<br><a href="' . $existingUrl . '">' . $existingRef . '</a>'
. ' Vérifiez avant de soumettre à nouveau.'; . '<br>Vérifiez avant de soumettre à nouveau.';
App::flash('warning', $warningHtml); App::flash('warning', $warningHtml);
$_SESSION['form_data'] = $_POST; $_SESSION['form_data'] = $_POST;

View File

@@ -13,7 +13,7 @@ AdminAuth::requireLogin();
$flash = App::consumeFlash(); $flash = App::consumeFlash();
if (!$flash['error'] && !$flash['success']) { if (!$flash['error'] && !$flash['success'] && !$flash['warning']) {
http_response_code(204); http_response_code(204);
exit; exit;
} }

View File

@@ -186,6 +186,7 @@
background: var(--bg-secondary); background: var(--bg-secondary);
border-color: var(--warning); border-color: var(--warning);
color: var(--text-primary); color: var(--text-primary);
animation: toast-enter 0.35s ease-out; /* no fade-out — stays until dismissed */
} }
.toast--warning a { .toast--warning a {

View File

@@ -360,6 +360,7 @@ label:has(+ div > input:required)::after {
background: var(--warning-muted-bg, rgba(251,202,81,.12)); background: var(--warning-muted-bg, rgba(251,202,81,.12));
border-color: var(--warning-muted-border, rgba(251,202,81,.35)); border-color: var(--warning-muted-border, rgba(251,202,81,.35));
color: var(--text-primary); color: var(--text-primary);
white-space: pre-line;
} }
/* ── Share link badge ───────────────────────────────────────────────────── */ /* ── Share link badge ───────────────────────────────────────────────────── */

View File

@@ -1,11 +1,11 @@
<?php <?php
/** /**
* Partage Entry point for shared student submission forms. * Partage - Entry point for shared student submission forms.
* *
* Routes: * Routes:
* /partage/<slug> Render the share-link form (or password gate) * /partage/<slug> - Render the share-link form (or password gate)
* /partage/<slug>/submit POST endpoint for form submissions via share link * /partage/<slug>/submit - POST endpoint for form submissions via share link
* /partage/recapitulatif.php?id=N Post-submission confirmation page * /partage/recapitulatif.php?id=N - Post-submission confirmation page
*/ */
require_once __DIR__ . '/../../bootstrap.php'; require_once __DIR__ . '/../../bootstrap.php';
@@ -83,7 +83,7 @@ if (!$validationResult['valid']) {
exit; exit;
} }
// Link is valid render the form // Link is valid - render the form
$link = $validationResult['link']; $link = $validationResult['link'];
renderShareLinkForm($slug, $link); renderShareLinkForm($slug, $link);
@@ -222,7 +222,7 @@ function renderShareLinkForm(string $slug, array $link): void
// Build old()-compatible callable from $formData (share forms use the array variant). // Build old()-compatible callable from $formData (share forms use the array variant).
$shareOldFn = fn(string $key, string $default = '') => old($formData, $key, $default); $shareOldFn = fn(string $key, string $default = '') => old($formData, $key, $default);
// No autofocus in the share form identity function. // No autofocus in the share form - identity function.
$shareWithAutofocusFn = fn(string $field, array $attrs = []) => $attrs; $shareWithAutofocusFn = fn(string $field, array $attrs = []) => $attrs;
// Load all form help blocks in one query. // Load all form help blocks in one query.
@@ -267,7 +267,8 @@ function renderShareLinkForm(string $slug, array $link): void
<div class="flash-error" role="alert"><?= htmlspecialchars($flashError) ?></div> <div class="flash-error" role="alert"><?= htmlspecialchars($flashError) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($flashWarning): ?> <?php if ($flashWarning): ?>
<div class="flash-warning" role="alert"><?= htmlspecialchars($flashWarning) ?></div> <div class="flash-warning" id="flash-warning" role="alert" tabindex="-1"><?= htmlspecialchars($flashWarning) ?></div>
<script>document.addEventListener('DOMContentLoaded',function(){var el=document.getElementById('flash-warning');if(el){el.scrollIntoView({behavior:'smooth',block:'center'});el.focus();}});</script>
<?php endif; ?> <?php endif; ?>
<?php if ($flashSuccess): ?> <?php if ($flashSuccess): ?>
<div class="flash-success" role="alert"><?= htmlspecialchars($flashSuccess) ?></div> <div class="flash-success" role="alert"><?= htmlspecialchars($flashSuccess) ?></div>
@@ -460,7 +461,7 @@ function handleShareLinkSubmission(string $slug): void
unset($_SESSION[$shareCsrfKey]); unset($_SESSION[$shareCsrfKey]);
unset($_SESSION['share_verified_' . $slug]); unset($_SESSION['share_verified_' . $slug]);
// Send confirmation e-mail on delivery failure, redirect to retry page // Send confirmation e-mail - on delivery failure, redirect to retry page
$emailError = null; $emailError = null;
try { try {
$emailSent = StudentEmail::sendConfirmation(Database::getInstance(), $thesisId, $_POST); $emailSent = StudentEmail::sendConfirmation(Database::getInstance(), $thesisId, $_POST);
@@ -472,7 +473,7 @@ function handleShareLinkSubmission(string $slug): void
header('Location: /partage/retry-email?id=' . urlencode((string)$thesisId)); header('Location: /partage/retry-email?id=' . urlencode((string)$thesisId));
exit(); exit();
} }
// Non-recipient errors (relay down, etc.) skip email silently // Non-recipient errors (relay down, etc.) - skip email silently
$_SESSION['share_email_sent'] = false; $_SESSION['share_email_sent'] = false;
} }
@@ -488,9 +489,10 @@ function handleShareLinkSubmission(string $slug): void
error_log('Share link duplicate submission: ' . $e->getMessage()); error_log('Share link duplicate submission: ' . $e->getMessage());
// Repopulate the form and surface a clear warning to the student. // Repopulate the form and surface a clear warning to the student.
$_SESSION['_flash_warning'] = 'Votre soumission ressemble à un TFE déjà enregistré (' // Store as plain text — htmlspecialchars() is applied at render time.
. htmlspecialchars($e->existingIdentifier . ' — ' . $e->existingTitle . ', ' . $e->existingYear) $_SESSION['_flash_warning'] = 'Votre soumission ressemble à un TFE déjà enregistré.'
. '). Si vous pensez quil sagit dune erreur, veuillez contacter léquipe.'; . "\n" . $e->existingIdentifier . ' — ' . $e->existingTitle . ' (' . $e->existingYear . ')'
. "\nSi vous pensez qu'il s'agit d'une erreur, veuillez contacter l'équipe.";
$_SESSION['form_data_share_' . $slug] = $_POST; $_SESSION['form_data_share_' . $slug] = $_POST;
$_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token $_SESSION[$shareCsrfKey] = bin2hex(random_bytes(32)); // Regenerate token

View File

@@ -9,3 +9,8 @@
{"source":"partage","action":"submit","status":"success","thesis_id":23,"identifier":"2025-020","author":"Zoé Lambert","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T11:46:49+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"} {"source":"partage","action":"submit","status":"success","thesis_id":23,"identifier":"2025-020","author":"Zoé Lambert","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T11:46:49+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"partage","action":"submit","status":"success","thesis_id":24,"identifier":"2025-021","author":"Emma Renard","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T11:49:49+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"} {"source":"partage","action":"submit","status":"success","thesis_id":24,"identifier":"2025-021","author":"Emma Renard","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T11:49:49+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"partage","action":"submit","status":"success","thesis_id":25,"identifier":"2025-001","author":"Emma Renard","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T12:17:35+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"} {"source":"partage","action":"submit","status":"success","thesis_id":25,"identifier":"2025-001","author":"Emma Renard","share_slug":"20260429-DZESJT6X","timestamp":"2026-04-30T12:17:35+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"admin","action":"submit","status":"success","thesis_id":37,"identifier":"2025-012","author":"Théo Marchand","timestamp":"2026-05-04T14:56:37+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"admin","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","timestamp":"2026-05-04T14:56:53+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"partage","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","share_slug":"20260429-DZESJT6X","timestamp":"2026-05-04T15:01:08+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"partage","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","share_slug":"20260429-DZESJT6X","timestamp":"2026-05-04T15:05:04+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}
{"source":"admin","action":"submit","status":"duplicate","author":"Théo Marchand","existing_thesis_id":37,"existing_identifier":"2025-012","timestamp":"2026-05-04T15:05:31+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"}

View File

@@ -14,5 +14,13 @@
<script><?= $extraJsInline ?></script> <script><?= $extraJsInline ?></script>
<?php endif; ?> <?php endif; ?>
<script src="/assets/js/htmx.min.js"></script> <script src="/assets/js/htmx.min.js"></script>
<script>
document.body.addEventListener('htmx:afterSettle', function (e) {
if (e.target && e.target.id === 'toast-region') {
var warn = e.target.querySelector('.toast--warning');
if (warn) { warn.setAttribute('tabindex', '-1'); warn.focus(); }
}
});
</script>
</body> </body>
</html> </html>