Implement TFE file access restriction feature (complete)

Requirements:
- parametres.php toggle: 'restricted_files_enabled' enables/disables the feature
- Public TFE page: when enabled + access_type=Interne, hides files, shows French
  restriction message + access request form (metadata/synopsis still visible)
- ERG emails (@erg.school / @erg.be): auto-approve, send 24h access link immediately
- External emails: show justification textarea, create pending request, notify admin
- Admin panel /admin/file-access.php: approve/reject requests with optional notes,
  sends access email on approval (linked from admin nav with pending count badge)

Security:
- One-time 24h email tokens (used_at + is_valid=0 on first click)
- Token redeemed via POST /validate-access (GET shows confirmation page only)
- Long-lived 30-day browser session in file_access_sessions table
- Cookie: HttpOnly + Secure + SameSite=Strict
- CSRF on all mutations, rate limiting on request submission
- Audit trail: IP, UA, event, timestamp in file_access_audit

Bug fixes:
- admin/file-access.php: $vars never extract()ed → page was blank
- Template had self-contained head/footer includes (double-include)
- Admin approval URL used $requestId instead of $request['thesis_id']
- App::boot() now starts session so CSRF token works on public pages
- Dispatcher routes /validate-access and /request-access through front controller
This commit is contained in:
Pontoporeia
2026-04-27 20:12:43 +02:00
parent 5c776dd39e
commit 27e1b6828d
21 changed files with 6256 additions and 15 deletions

View File

@@ -0,0 +1,199 @@
<main id="main-content">
<h1>Demandes d'accès aux fichiers</h1>
<div class="access-req-stats">
<div class="access-req-stat-card">
<span class="access-req-stat-number"><?= $pendingCount ?></span>
<span class="access-req-stat-label">En attente</span>
</div>
<div class="access-req-stat-card">
<span class="access-req-stat-number"><?= $approvedCount ?></span>
<span class="access-req-stat-label">Approuvées</span>
</div>
<div class="access-req-stat-card">
<span class="access-req-stat-number"><?= $rejectedCount ?></span>
<span class="access-req-stat-label">Rejetées</span>
</div>
</div>
<nav class="access-req-tabs">
<a href="?status=pending" class="access-req-tab <?= $status === 'pending' ? 'active' : '' ?>">
En attente <?= $pendingCount > 0 ? "({$pendingCount})" : '' ?>
</a>
<a href="?status=approved" class="access-req-tab <?= $status === 'approved' ? 'active' : '' ?>">
Approuvées
</a>
<a href="?status=rejected" class="access-req-tab <?= $status === 'rejected' ? 'active' : '' ?>">
Rejetées
</a>
</nav>
<?php if (empty($requests)): ?>
<div class="access-req-empty">
<p>Aucune demande <?= $status === 'pending' ? 'en attente' : ($status === 'approved' ? 'approuvée' : 'rejetée') ?>.</p>
</div>
<?php else: ?>
<div class="access-req-list">
<?php foreach ($requests as $req): ?>
<div class="access-req-card">
<div class="access-req-card__header">
<div class="access-req-card__thesis">
<h3><?= htmlspecialchars($req['title']) ?></h3>
<p class="access-req-card__authors">
<?php if (!empty($req['authors'])): ?>
par <?= htmlspecialchars($req['authors']) ?>
<?php endif; ?>
<?php if (!empty($req['year'])): ?>
— <?= htmlspecialchars($req['year']) ?>
<?php endif; ?>
</p>
</div>
<div class="access-req-card__meta">
<span class="access-req-badge access-req-badge--<?= $status ?>">
<?= $status === 'pending' ? 'En attente' : ($status === 'approved' ? 'Approuvée' : 'Rejetée') ?>
</span>
</div>
</div>
<div class="access-req-card__body">
<div class="access-req-card__info">
<div>
<strong>Email :</strong>
<a href="mailto:<?= htmlspecialchars($req['email']) ?>">
<?= htmlspecialchars($req['email']) ?>
</a>
</div>
<div>
<strong>Date :</strong>
<?= date('d/m/Y à H:i', strtotime($req['created_at'])) ?>
</div>
<?php if ($status === 'approved' && !empty($req['approved_at'])): ?>
<div>
<strong>Approuvée le :</strong>
<?= date('d/m/Y à H:i', strtotime($req['approved_at'])) ?>
</div>
<?php endif; ?>
<?php if ($status === 'rejected' && !empty($req['approved_at'])): ?>
<div>
<strong>Rejetée le :</strong>
<?= date('d/m/Y à H:i', strtotime($req['approved_at'])) ?>
</div>
<?php endif; ?>
</div>
<?php if (!empty($req['justification'])): ?>
<div class="access-req-card__justification">
<strong>Justification :</strong>
<p><?= nl2br(htmlspecialchars($req['justification'])) ?></p>
</div>
<?php endif; ?>
<?php if ($status === 'rejected' && !empty($req['admin_notes'])): ?>
<div class="access-req-card__admin-notes">
<strong>Note de l'administrateur :</strong>
<p><?= nl2br(htmlspecialchars($req['admin_notes'])) ?></p>
</div>
<?php endif; ?>
<?php if ($status === 'pending'): ?>
<div class="access-req-card__actions">
<button type="button"
class="access-req-btn access-req-btn--approve"
onclick="openApproveDialog(<?= $req['id'] ?>)">
Approuver
</button>
<button type="button"
class="access-req-btn access-req-btn--reject"
onclick="openRejectDialog(<?= $req['id'] ?>)">
Rejeter
</button>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if ($totalPages > 1): ?>
<nav class="access-req-pagination">
<?php if ($page > 1): ?>
<a href="?status=<?= $status ?>&page=<?= $page - 1 ?>" class="access-req-pagination__link">
&larr; Précédent
</a>
<?php endif; ?>
<span class="access-req-pagination__info">
Page <?= $page ?> sur <?= $totalPages ?>
</span>
<?php if ($page < $totalPages): ?>
<a href="?status=<?= $status ?>&page=<?= $page + 1 ?>" class="access-req-pagination__link">
Suivant &rarr;
</a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php endif; ?>
</main>
<!-- Approve Dialog -->
<dialog id="approve-dialog" class="admin-dialog">
<div class="admin-dialog__header">
<h2>Approuver la demande</h2>
<button type="button" class="admin-dialog__close"
onclick="document.getElementById('approve-dialog').close()">&times;</button>
</div>
<form method="post" action="/admin/actions/access-request.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="request_id" id="approve-request-id">
<input type="hidden" name="action" value="approve">
<label for="approve-notes">Note optionnelle (inclus dans l'email) :</label>
<textarea name="admin_notes" id="approve-notes" rows="3"
placeholder="Message personnalisé pour le demandeur..."></textarea>
<div class="admin-form-footer">
<button type="submit" class="admin-btn">Approuver et envoyer email</button>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('approve-dialog').close()">Annuler</button>
</div>
</form>
</dialog>
<!-- Reject Dialog -->
<dialog id="reject-dialog" class="admin-dialog">
<div class="admin-dialog__header">
<h2>Rejeter la demande</h2>
<button type="button" class="admin-dialog__close"
onclick="document.getElementById('reject-dialog').close()">&times;</button>
</div>
<form method="post" action="/admin/actions/access-request.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="request_id" id="reject-request-id">
<input type="hidden" name="action" value="reject">
<label for="reject-notes">Raison du rejet (optionnel) :</label>
<textarea name="admin_notes" id="reject-notes" rows="3"
placeholder="Raison du rejet..."></textarea>
<div class="admin-form-footer">
<button type="submit" class="admin-btn admin-btn--danger">Rejeter</button>
<button type="button" class="admin-btn-secondary"
onclick="document.getElementById('reject-dialog').close()">Annuler</button>
</div>
</form>
</dialog>
<script>
function openApproveDialog(requestId) {
document.getElementById('approve-request-id').value = requestId;
document.getElementById('approve-dialog').showModal();
}
function openRejectDialog(requestId) {
document.getElementById('reject-request-id').value = requestId;
document.getElementById('reject-dialog').showModal();
}
</script>