feat: add objet field (tfe/thèse/frart) with share-link restriction and site-settings toggles

This commit is contained in:
Pontoporeia
2026-04-22 14:06:05 +02:00
parent dbaabaf8a0
commit d961f9533c
12 changed files with 128 additions and 10 deletions

View File

@@ -11,4 +11,5 @@
- [x] Extract form CSS into `form.css`; load it in admin add/edit via `$extraCss` and in student partage form directly; `system.css` now only used by `system.php`; `partage/thanks.php` rewritten to use design-system classes - [x] Extract form CSS into `form.css`; load it in admin add/edit via `$extraCss` and in student partage form directly; `system.css` now only used by `system.php`; `partage/thanks.php` rewritten to use design-system classes
- [x] Fix student form: add missing `v_smtp_active` view to `schema.sql` (SMTP was silently skipped on fresh installs); fix `thanks.php` redirect (was `/partage/thanks.php` — blocked by nginx PHP deny rule); route `/partage/thanks` through `index.php` special-case handler - [x] Fix student form: add missing `v_smtp_active` view to `schema.sql` (SMTP was silently skipped on fresh installs); fix `thanks.php` redirect (was `/partage/thanks.php` — blocked by nginx PHP deny rule); route `/partage/thanks` through `index.php` special-case handler
- [x] Merge all migration SQL into schema.sql; delete migrations/ folder; simplify migrate.sh (009 share_links, 014 ap_programs, 011 apropos seed, missing semicolon fix) - [x] Merge all migration SQL into schema.sql; delete migrations/ folder; simplify migrate.sh (009 share_links, 014 ap_programs, 011 apropos seed, missing semicolon fix)
- [x] Add `objet` field (tfe/thèse/frart) to theses; `objet_restriction` on share_links; objet_these/frart_enabled site_settings; wire into partage form, parametres, and acces-etudiante
- [x] Fix student form scroll (add `overflow-y:auto` to `.student-body`); move all remaining inline styles from partage error/password-gate pages into `form.css` - [x] Fix student form scroll (add `overflow-y:auto` to `.student-body`); move all remaining inline styles from partage error/password-gate pages into `form.css`

View File

@@ -25,13 +25,14 @@ switch ($action) {
$expiresRaw = !empty($_POST['expires_at']) ? trim($_POST['expires_at']) : null; $expiresRaw = !empty($_POST['expires_at']) ? trim($_POST['expires_at']) : null;
$expiresAt = null; $expiresAt = null;
if ($expiresRaw) { if ($expiresRaw) {
// datetime-local gives "YYYY-MM-DDTHH:MM"
$expiresAt = date('Y-m-d H:i:s', strtotime($expiresRaw)); $expiresAt = date('Y-m-d H:i:s', strtotime($expiresRaw));
if ($expiresAt <= date('Y-m-d H:i:s')) { if ($expiresAt <= date('Y-m-d H:i:s')) {
App::redirect('/admin/acces-etudiante.php', error: "La date d'expiration doit être dans le futur."); App::redirect('/admin/acces-etudiante.php', error: "La date d'expiration doit être dans le futur.");
} }
} }
$shareLink->create(1, $password, $expiresAt); $objetRaw = $_POST['objet_restriction'] ?? '';
$objetRestriction = in_array($objetRaw, ['tfe', 'thèse', 'frart'], true) ? $objetRaw : null;
$shareLink->create(1, $password, $expiresAt, $objetRestriction);
App::redirect('/admin/acces-etudiante.php', success: 'Lien d\'accès créé.'); App::redirect('/admin/acces-etudiante.php', success: 'Lien d\'accès créé.');
break; break;

View File

@@ -17,13 +17,16 @@ $db = new Database();
$section = $_POST['section'] ?? ''; $section = $_POST['section'] ?? '';
if ($section === 'formulaire') { if ($section === 'formulaire') {
// Save access-type toggle settings
$allowed = ['access_type_libre_enabled', 'access_type_interne_enabled', 'access_type_interdit_enabled']; $allowed = ['access_type_libre_enabled', 'access_type_interne_enabled', 'access_type_interdit_enabled'];
foreach ($allowed as $key) { foreach ($allowed as $key) {
$value = isset($_POST[$key]) ? '1' : '0'; $value = isset($_POST[$key]) ? '1' : '0';
$db->setSetting($key, $value); $db->setSetting($key, $value);
} }
App::flash('success', "Paramètres du formulaire mis à jour."); App::flash('success', "Paramètres du formulaire mis à jour.");
} elseif ($section === 'objet_types') {
$db->setSetting('objet_these_enabled', isset($_POST['objet_these_enabled']) ? '1' : '0');
$db->setSetting('objet_frart_enabled', isset($_POST['objet_frart_enabled']) ? '1' : '0');
App::flash('success', "Types de travaux mis à jour.");
} elseif ($section === 'smtp') { } elseif ($section === 'smtp') {
$smtpData = [ $smtpData = [
'host' => $_POST['smtp_host'] ?? '', 'host' => $_POST['smtp_host'] ?? '',

View File

@@ -185,6 +185,19 @@ function renderShareLinkForm(string $slug, array $link): void
$formData = $_SESSION['form_data_share_' . $slug] ?? []; $formData = $_SESSION['form_data_share_' . $slug] ?? [];
unset($_SESSION['form_data_share_' . $slug]); unset($_SESSION['form_data_share_' . $slug]);
// Determine allowed objet values for this link
$siteSettings = Database::getInstance()->getAllSettings();
$objetRestriction = $link['objet_restriction'] ?? null;
if ($objetRestriction !== null) {
// Link is locked to one type — always show only that
$allowedObjet = [$objetRestriction];
} else {
// Build from enabled site settings
$allowedObjet = ['tfe'];
if (($siteSettings['objet_these_enabled'] ?? '1') === '1') $allowedObjet[] = 'thèse';
if (($siteSettings['objet_frart_enabled'] ?? '1') === '1') $allowedObjet[] = 'frart';
}
// Generate a CSRF token specific to this share link (stored in session) // Generate a CSRF token specific to this share link (stored in session)
$shareCsrfKey = 'share_csrf_' . $slug; $shareCsrfKey = 'share_csrf_' . $slug;
if (empty($_SESSION[$shareCsrfKey])) { if (empty($_SESSION[$shareCsrfKey])) {
@@ -236,6 +249,23 @@ function renderShareLinkForm(string $slug, array $link): void
<fieldset> <fieldset>
<legend>Informations du TFE</legend> <legend>Informations du TFE</legend>
<?php if (count($allowedObjet) > 1): ?>
<div class="admin-form-group">
<label>Type de travail&nbsp;: <span class="asterisk">*</span></label>
<div class="form-radio-group">
<?php foreach ($allowedObjet as $objetVal): ?>
<label class="admin-checkbox-label">
<input type="radio" name="objet" value="<?= htmlspecialchars($objetVal) ?>"
<?= (old($formData, 'objet') ?: $allowedObjet[0]) === $objetVal ? 'checked' : '' ?> required>
<?= htmlspecialchars(ucfirst($objetVal)) ?>
</label>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<input type="hidden" name="objet" value="<?= htmlspecialchars($allowedObjet[0]) ?>">
<?php endif; ?>
<?php $name = 'titre'; $label = 'Titre :'; $value = old($formData, 'titre'); $required = true; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> <?php $name = 'titre'; $label = 'Titre :'; $value = old($formData, 'titre'); $required = true; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'subtitle'; $label = 'Sous-titre (si applicable) :'; $value = old($formData, 'subtitle'); $required = false; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> <?php $name = 'subtitle'; $label = 'Sous-titre (si applicable) :'; $value = old($formData, 'subtitle'); $required = false; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>
<?php $name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = old($formData, 'auteurice'); $required = true; $attrs = ['autocomplete' => 'name']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?> <?php $name = 'auteurice'; $label = 'Auteur·ice(s) :'; $value = old($formData, 'auteurice'); $required = true; $attrs = ['autocomplete' => 'name']; include APP_ROOT . '/templates/partials/form/text-field.php'; ?>

View File

@@ -127,6 +127,7 @@ class ThesisCreateController
'baiu_link' => $data['lien'], 'baiu_link' => $data['lien'],
'license_id' => $data['licenseId'], 'license_id' => $data['licenseId'],
'access_type_id' => $data['accessTypeId'], 'access_type_id' => $data['accessTypeId'],
'objet' => $data['objet'],
'author_id' => $authorId, 'author_id' => $authorId,
]); ]);
@@ -275,6 +276,10 @@ class ThesisCreateController
$accessTypeId = 2; // Interne $accessTypeId = 2; // Interne
} }
// Objet — restricted to valid values
$validObjet = ['tfe', 'thèse', 'frart'];
$objet = in_array($post['objet'] ?? '', $validObjet, true) ? $post['objet'] : 'tfe';
// External link (optional) // External link (optional)
$lien = ''; $lien = '';
if (!empty($post['lien'])) { if (!empty($post['lien'])) {
@@ -298,7 +303,7 @@ class ThesisCreateController
'auteurName', 'mail', 'showContact', 'confirmationEmail', 'annee', 'orientationId', 'apProgramId', 'auteurName', 'mail', 'showContact', 'confirmationEmail', 'annee', 'orientationId', 'apProgramId',
'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo', 'finalityId', 'titre', 'subtitle', 'synopsis', 'durationInfo',
'juryMembers', 'keywords', 'languageIds', 'formatIds', 'juryMembers', 'keywords', 'languageIds', 'formatIds',
'licenseId', 'lien', 'accessTypeId' 'licenseId', 'lien', 'accessTypeId', 'objet'
); );
} }

View File

@@ -1566,11 +1566,15 @@ class Database {
synopsis, file_size_info, synopsis, file_size_info,
baiu_link, license_id, baiu_link, license_id,
access_type_id, access_type_id,
objet,
is_published, is_published,
submitted_at submitted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)
"); ");
$validObjet = ['tfe', 'thèse', 'frart'];
$objet = in_array($data['objet'] ?? '', $validObjet, true) ? $data['objet'] : 'tfe';
$stmt->execute([ $stmt->execute([
$identifier, $identifier,
$data['title'], $data['title'],
@@ -1584,6 +1588,7 @@ class Database {
!empty($data['baiu_link']) ? $data['baiu_link'] : null, !empty($data['baiu_link']) ? $data['baiu_link'] : null,
isset($data['license_id']) ? $data['license_id'] : null, isset($data['license_id']) ? $data['license_id'] : null,
isset($data['access_type_id']) ? (int)$data['access_type_id'] : 2, // default: Interne isset($data['access_type_id']) ? (int)$data['access_type_id'] : 2, // default: Interne
$objet,
]); ]);
$thesisId = (int)$this->pdo->lastInsertId(); $thesisId = (int)$this->pdo->lastInsertId();

View File

@@ -48,16 +48,20 @@ class ShareLink
* @param string|null $expiresAt ISO-8601 expiration date, null = never expires * @param string|null $expiresAt ISO-8601 expiration date, null = never expires
* @return array The created link row * @return array The created link row
*/ */
public function create(int $createdBy, ?string $password = null, ?string $expiresAt = null): array public function create(int $createdBy, ?string $password = null, ?string $expiresAt = null, ?string $objetRestriction = null): array
{ {
$slug = self::generateSlug(); $slug = self::generateSlug();
$passwordHash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null; $passwordHash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null;
$validObjet = ['tfe', 'thèse', 'frart'];
$objetRestriction = ($objetRestriction !== null && in_array($objetRestriction, $validObjet, true))
? $objetRestriction
: null;
$stmt = $this->db->getConnection()->prepare( $stmt = $this->db->getConnection()->prepare(
"INSERT INTO share_links (slug, password_hash, is_active, created_by, expires_at) "INSERT INTO share_links (slug, objet_restriction, password_hash, is_active, created_by, expires_at)
VALUES (?, ?, 1, ?, ?)" VALUES (?, ?, ?, 1, ?, ?)"
); );
$stmt->execute([$slug, $passwordHash, $createdBy, $expiresAt]); $stmt->execute([$slug, $objetRestriction, $passwordHash, $createdBy, $expiresAt]);
return $this->findBySlug($slug); return $this->findBySlug($slug);
} }

Binary file not shown.

View File

@@ -163,6 +163,7 @@ CREATE TABLE IF NOT EXISTS theses (
-- Type of work -- Type of work
is_doctoral BOOLEAN DEFAULT 0, -- 0 for TFE, 1 for doctoral thesis is_doctoral BOOLEAN DEFAULT 0, -- 0 for TFE, 1 for doctoral thesis
objet TEXT NOT NULL DEFAULT 'tfe' CHECK (objet IN ('tfe', 'thèse', 'frart')),
-- Academic details -- Academic details
orientation_id INTEGER, orientation_id INTEGER,
@@ -295,7 +296,9 @@ INSERT OR IGNORE INTO site_settings (key, value) VALUES
('access_type_interdit_enabled', '1'), ('access_type_interdit_enabled', '1'),
('access_type_interne_enabled', '1'), ('access_type_interne_enabled', '1'),
('access_type_libre_enabled', '0'), ('access_type_libre_enabled', '0'),
('admin_password_hash', ''); ('admin_password_hash', ''),
('objet_these_enabled', '1'),
('objet_frart_enabled', '1');
-- ============================================================================ -- ============================================================================
-- STATIC PAGES / CONTENT MANAGEMENT -- STATIC PAGES / CONTENT MANAGEMENT
@@ -331,6 +334,7 @@ INSERT OR IGNORE INTO pages (slug, title, content) VALUES
CREATE TABLE IF NOT EXISTS share_links ( CREATE TABLE IF NOT EXISTS share_links (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, -- Format: YYYYMMDD-<random>, e.g. 20260416-a3f9k2 slug TEXT NOT NULL UNIQUE, -- Format: YYYYMMDD-<random>, e.g. 20260416-a3f9k2
objet_restriction TEXT CHECK (objet_restriction IN ('tfe', 'thèse', 'frart')), -- NULL = no restriction
password_hash TEXT, -- bcrypt hash; NULL = no password required password_hash TEXT, -- bcrypt hash; NULL = no password required
is_active INTEGER NOT NULL DEFAULT 1, -- 1 = active, 0 = disabled is_active INTEGER NOT NULL DEFAULT 1, -- 1 = active, 0 = disabled
usage_count INTEGER NOT NULL DEFAULT 0, -- Number of successful submissions via this link usage_count INTEGER NOT NULL DEFAULT 0, -- Number of successful submissions via this link
@@ -478,6 +482,7 @@ SELECT
t.subtitle, t.subtitle,
t.year, t.year,
t.is_doctoral, t.is_doctoral,
t.objet,
o.name as orientation, o.name as orientation,
ap.name as ap_program, ap.name as ap_program,
ft.name as finality_type, ft.name as finality_type,

Binary file not shown.

View File

@@ -16,6 +16,7 @@
<thead> <thead>
<tr> <tr>
<th scope="col">Lien</th> <th scope="col">Lien</th>
<th scope="col">Objet</th>
<th scope="col">Statut</th> <th scope="col">Statut</th>
<th scope="col">Mot de passe</th> <th scope="col">Mot de passe</th>
<th scope="col">Utilisations</th> <th scope="col">Utilisations</th>
@@ -48,6 +49,13 @@
<code style="font-size:var(--step--2);color:var(--text-secondary);"><?= htmlspecialchars($link['slug']) ?></code> <code style="font-size:var(--step--2);color:var(--text-secondary);"><?= htmlspecialchars($link['slug']) ?></code>
<input type="hidden" id="url-<?= $link['id'] ?>" value="<?= $fullUrl ?>"> <input type="hidden" id="url-<?= $link['id'] ?>" value="<?= $fullUrl ?>">
</td> </td>
<td>
<?php if ($link['objet_restriction']): ?>
<span class="status-badge"><?= htmlspecialchars($link['objet_restriction']) ?></span>
<?php else: ?>
<span style="color:var(--text-secondary);font-size:var(--step--2);">Tous</span>
<?php endif; ?>
</td>
<td> <td>
<?php if ($isExpired): ?> <?php if ($isExpired): ?>
<span class="status-badge status-pending"><?= $statusLabel ?></span> <span class="status-badge status-pending"><?= $statusLabel ?></span>
@@ -114,6 +122,16 @@
<form method="post" action="actions/acces-etudiante.php" class="admin-form"> <form method="post" action="actions/acces-etudiante.php" 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']) ?>">
<input type="hidden" name="action" value="create"> <input type="hidden" name="action" value="create">
<div>
<label for="create-objet">Type d'objet (optionnel)</label>
<select id="create-objet" name="objet_restriction">
<option value="">— Tous les types —</option>
<option value="tfe">TFE</option>
<option value="thèse">Thèse</option>
<option value="frart">Frart</option>
</select>
<small>Restreint ce lien à un seul type de soumission.</small>
</div>
<div> <div>
<label for="create-password">Mot de passe (optionnel)</label> <label for="create-password">Mot de passe (optionnel)</label>
<input type="password" id="create-password" name="password" autocomplete="new-password"> <input type="password" id="create-password" name="password" autocomplete="new-password">

View File

@@ -106,6 +106,52 @@
</form> </form>
</section> </section>
<!-- ══════════════════════════════════════════════════════════════
TYPES D'OBJET
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-objet-title">
<h2 id="settings-objet-title">Types de travaux</h2>
<p>Active ou désactive les types de travaux dans les formulaires et la consultation. Un type désactivé ne peut plus être soumis ni affiché sur le site.</p>
<p class="param-note">Le type <strong>TFE</strong> est toujours actif et ne peut pas être désactivé.</p>
<form method="post" action="actions/settings.php" class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="objet_types">
<fieldset>
<legend>Types disponibles</legend>
<label class="param-checkbox param-checkbox--disabled">
<input type="checkbox" disabled checked>
<span>
<strong>TFE</strong><br>
<small>Travail de fin d'études — toujours actif</small>
</span>
</label>
<label class="param-checkbox">
<input type="checkbox" name="objet_these_enabled" value="1"
<?= ($siteSettings['objet_these_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<span>
<strong>Thèse</strong><br>
<small>Thèses doctorales</small>
</span>
</label>
<label class="param-checkbox">
<input type="checkbox" name="objet_frart_enabled" value="1"
<?= ($siteSettings['objet_frart_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<span>
<strong>Frart</strong><br>
<small>Formation de recherche en art</small>
</span>
</label>
</fieldset>
<button type="submit">Enregistrer</button>
</form>
</section>
<!-- ══════════════════════════════════════════════════════════════ <!-- ══════════════════════════════════════════════════════════════
RELAY SMTP RELAY SMTP
══════════════════════════════════════════════════════════════ --> ══════════════════════════════════════════════════════════════ -->