fix: mark languages as required, add required-field visual indicators on both forms

- checkbox-list.php: support $required prop → adds required + aria-required on fieldset
- add.php: languages checkbox now marked required (matches server-side validation)
- partage/index.php: same for student form
- admin.css: dashed border on required inputs, bold labels, red asterisk via :has(), "Champs obligatoires" note
- Both forms now show "* Champs obligatoires" note at top

Server-side required fields = titre, auteurice, synopsis, année, orientation, ap, finality, languages (≥1), access_type_id, confirmation_email. All now have required attribute + visual asterisk.
This commit is contained in:
Pontoporeia
2026-04-20 16:19:55 +02:00
parent e21a4d81a2
commit 1b02ccb1d5
6 changed files with 51 additions and 93 deletions

95
TODO.md
View File

@@ -1,89 +1,6 @@
# TODO [x] Audit required fields in both admin and student forms
[x] Fix: `checkbox-list.php` partial supports `$required` (fieldset gets `required` + `aria-required`)
- [x] Replace inline alert CSS in admin.css with floating bottom-center toast styles (fixed, z-index, animation) [x] Mark `languages` checkbox list as required in both forms
- [x] Update flash-messages.php partial to output `.toast` markup in hidden container for footer JS [x] Added visual `*` indicator on all required fields (CSS bold labels + red asterisk)
- [x] Add toast container HTML + JS to admin footer.php (centralised, 4s auto-dismiss with fade-out) [x] Added "* Champs obligatoires" note at top of both forms
- [x] Remove redundant flash-messages.php includes from all admin pages (8 files) [x] Verified: all server-side required fields match client-side `required` attrs and visual indicators
- [x] Convert hardcoded alerts in login.php, thanks.php, index.php import to `.toast` class
- [x] Update admin.css dialog rule from `[role=alert/status]` to `.toast`
- [x] Commit with jj
- [x] Move DB export from admin/index.php to admin/parametres.php (maintenance section)
- [x] Reorganize src/ - move 7 controllers to src/Controllers/
- [x] Create Controllers directory
- [x] Move controller files (Home, Tfe, Search, ThesisCreate, ThesisEdit, Export, System)
- [x] Update all require_once paths across codebase
- [x] Move stray test.db from root to storage/
- [x] Store admin password hash in DB (site_settings) instead of config file
- [x] Create migration 013
- [x] Update AdminAuth to read hash from DB
- [x] Update bootstrap.php — remove credential file loading
- [x] Update parametres.php — status check from DB
- [x] Update actions/account.php — write hash to DB
- [x] Update login.php — dev-mode check
- [x] Update header.php — dev check
- [x] Delete config/admin_credentials.example.php
## Now: Single Entry Point Routing
### Phase 1: Dispatcher refinement
- [x] MediaController: extract media.php logic into MediaController class
- [x] Create src/Controllers/MediaController.php
- [x] Move path validation + storage jail + MIME check + streaming
- [x] Wire into Dispatcher for /media route
- [x] Delete app/public/media.php
- [ ] Update Dispatcher to handle all routes directly (no require APP_ROOT/public/*.php)
### Phase 2: Single entry point
- [x] Create app/public/index.php as front controller
- [x] Move bootstrap logic into entry point (bootstrap.php stays for admin)
- [x] Load and invoke Dispatcher
- [x] Move old public/*.php views into templates/public/
- [x] search.php → templates/public/search.php
- [x] tfe.php → templates/public/tfe.php
- [x] apropos.php → templates/public/about.php
- [x] repertoire.php → templates/public/repertoire.php
- [x] Delete old direct-access public/*.php files
- [x] Delete public/index.php (replaced by front controller)
- [x] Delete public/search.php
- [x] Delete public/tfe.php
- [x] Delete public/apropos.php
- [x] Delete public/licence.php
- [x] Delete public/repertoire.php
- [x] Update Dispatcher.render to use templates/public/ views
- [x] Update Dispatcher to render full pages (head + header + view + footer) instead of requiring bootstrap
- [x] Ensure admin/index.php bootstraps its own path (not affected by front controller)
- [x] Fix config/config.php path mess — inline getDatabasePath() into Database.php, delete config/config.php
### Phase 3: Server config
- [ ] Update router.php — route all PHP requests to Dispatcher
- [ ] Update nginx config — point all public routes to index.php via try_files
- [ ] Replace per-file `location ~ \.php$` with front-controller pattern
- [x] Clean URL updates
- [x] Remove .php from all internal links (header, views, controllers)
- [x] Add clean routes to Dispatcher (/search, /tfe, /media)
- [x] Update og:url tags in controllers to use clean URLs
- [x] Update TfeController redirect to /
- [x] Update header.php action URLs
- [x] Commit current state
- [ ] Test all routes (/, /search, /tfe, /repertoire, /apropos, /licence, /media, /live-reload)
# Now: Confirmation email on student form submission
- [x] Create src/StudentEmail.php — builds HTML recap email, extracts email from contact field, uses SmtpRelay to send
- [x] Wire StudentEmail::sendConfirmation() into partage/index.php handleShareLinkSubmission() after successful thesis creation
- [x] Pass email-sent flag via session to /partage/thanks.php
- [x] Update partage/thanks.php — show "email sent" notice with styled green badge when confirmation was sent
- [x] Add "Visiter" (👁 Visit) button to student link action row in acces-etudiante.php
- [x] Add link (target _blank) to /partage/<slug>
- [x] Add .admin-btn-visit / .admin-btn-visit:hover CSS in admin.css
- [x] Add required confirmation_email field to both student forms (partage/index.php + admin/add.php)
- [x] New fieldset at end of form with type="email", required
- [x] ThesisCreateController validates confirmation_email is present and valid
- [x] StudentEmail uses confirmation_email directly (no more extractEmail hack)
- [x] Autofocus mapping added for confirmation_email validation errors

View File

@@ -59,6 +59,7 @@ include APP_ROOT . '/templates/header.php';
<p class="required-note"><span class="asterisk">*</span> Champs obligatoires</p>
<form action="actions/formulaire.php" method="post" enctype="multipart/form-data" class="admin-form"> <form action="actions/formulaire.php" method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
@@ -107,7 +108,7 @@ include APP_ROOT . '/templates/header.php';
<?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = $formData['ap'] ?? ''; $required = true; $placeholder = ''; $attrs = withAutofocus('ap'); include APP_ROOT . '/templates/partials/form/select-field.php'; ?> <?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = $formData['ap'] ?? ''; $required = true; $placeholder = ''; $attrs = withAutofocus('ap'); include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = $formData['finality'] ?? ''; $required = true; $placeholder = ''; $attrs = withAutofocus('finality'); include APP_ROOT . '/templates/partials/form/select-field.php'; ?> <?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = $formData['finality'] ?? ''; $required = true; $placeholder = ''; $attrs = withAutofocus('finality'); include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $formData['languages'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?> <?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $formData['languages'] ?? []; $required = true; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $formData['formats'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?> <?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $formData['formats'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'tag'; $label = 'Mots-clés :'; $value = old('tag'); $placeholder = 'sociologie, anthropologie, ...'; $hint = 'Séparez par des virgules. Max 10 mots-clés.'; $attrs = withAutofocus('tag'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?> <?php $name = 'tag'; $label = 'Mots-clés :'; $value = old('tag'); $placeholder = 'sociologie, anthropologie, ...'; $hint = 'Séparez par des virgules. Max 10 mots-clés.'; $attrs = withAutofocus('tag'); include APP_ROOT . '/templates/partials/form/text-field.php'; ?>

View File

@@ -147,6 +147,43 @@
padding-right: 1.2rem; padding-right: 1.2rem;
} }
/* Required-field indicator */
.admin-form
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not(
[type="hidden"]
):not([type="submit"]):required,
.admin-form select:required,
.admin-form textarea:required {
border-bottom-style: dashed;
}
.admin-form
div:has(
input:required:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"])) > label,
.admin-form div:has(select:required) > label,
.admin-form div:has(textarea:required) > label {
font-weight: 600;
}
/* Required-field indicator (student form / generic labels) */
label:has(+ input:required:not([type="hidden"]))::after,
label:has(+ select:required)::after,
label:has(+ textarea:required)::after,
label:has(+ div > input:required)::after {
content: " *";
color: var(--error, #c00);
}
/* Visually-hidden "required fields marked with *" note */
.required-note {
font-size: var(--step--2);
color: var(--text-secondary);
margin-bottom: var(--space-xs);
}
.required-note .asterisk {
color: var(--error, #c00);
}
/* File inputs */ /* File inputs */
.admin-file-input { .admin-file-input {
display: flex; display: flex;

View File

@@ -308,6 +308,7 @@ function renderShareLinkForm(string $slug, array $link): void
<div class="flash-success" role="alert"><?= htmlspecialchars($flashSuccess) ?></div> <div class="flash-success" role="alert"><?= htmlspecialchars($flashSuccess) ?></div>
<?php endif; ?> <?php endif; ?>
<p class="required-note"><span class="asterisk">*</span> Champs obligatoires</p>
<form action="/partage/<?= urlencode($slug) ?>/submit" method="post" enctype="multipart/form-data" class="admin-form"> <form action="/partage/<?= urlencode($slug) ?>/submit" method="post" enctype="multipart/form-data" class="admin-form">
<input type="hidden" name="share_link_token" value="<?= htmlspecialchars($shareCsrfToken) ?>"> <input type="hidden" name="share_link_token" value="<?= htmlspecialchars($shareCsrfToken) ?>">
@@ -376,7 +377,7 @@ function renderShareLinkForm(string $slug, array $link): void
<?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = isset($formData['ap']) ? $formData['ap'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?> <?php $name = 'ap'; $label = 'Atelier pluridisciplinaire :'; $options = $apPrograms; $selected = isset($formData['ap']) ? $formData['ap'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = isset($formData['finality']) ? $formData['finality'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?> <?php $name = 'finality'; $label = 'Finalité du master :'; $options = $finalityTypes; $selected = isset($formData['finality']) ? $formData['finality'] : ''; $required = true; $placeholder = ''; include APP_ROOT . '/templates/partials/form/select-field.php'; ?>
<?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $formData['languages'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?> <?php $name = 'languages'; $label = 'Langue(s) :'; $options = $languages; $checked = $formData['languages'] ?? []; $required = true; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $formData['formats'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?> <?php $name = 'formats'; $label = 'Format(s) :'; $options = $formatTypes; $checked = $formData['formats'] ?? []; include APP_ROOT . '/templates/partials/form/checkbox-list.php'; ?>
<?php $name = 'tag'; $label = 'Mots-clés :'; $value = old($formData, 'tag'); $placeholder = 'sociologie, anthropologie, ...'; $hint = 'Séparez par des virgules. Max 10 mots-clés.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> <?php $name = 'tag'; $label = 'Mots-clés :'; $value = old($formData, 'tag'); $placeholder = 'sociologie, anthropologie, ...'; $hint = 'Séparez par des virgules. Max 10 mots-clés.'; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>

View File

@@ -12,13 +12,15 @@
* string $label — group label text * string $label — group label text
* array $options — each element must have 'id' and 'name' keys * array $options — each element must have 'id' and 'name' keys
* array $checked — array of 'id' values that are currently checked * array $checked — array of 'id' values that are currently checked
* bool $required — whether at least one checkbox must be checked; default false
*/ */
$checked = $checked ?? []; $checked = $checked ?? [];
$required = $required ?? false;
?> ?>
<div> <div>
<span class="admin-row-label"><?= htmlspecialchars($label) ?></span> <span class="admin-row-label"><?= htmlspecialchars($label) ?></span>
<fieldset class="admin-checkbox-group"> <fieldset class="admin-checkbox-group"<?= $required ? ' required aria-required="true"' : '' ?>>
<legend class="sr-only"><?= htmlspecialchars($label) ?></legend> <legend class="sr-only"><?= htmlspecialchars($label) ?></legend>
<ul> <ul>
<?php foreach ($options as $opt): ?> <?php foreach ($options as $opt): ?>