feat: roles, permissions, grille full-width, SSO display name

- Admin/roles : tableau des roles avec edition par role (/admin/role/<nom>)
- Permissions par role : cases a cocher groupees (Articles, Acces & lecture)
- Nouvelles capacites : propose/validate/publish articles (own/all), view_previews
- Nom technique auto-genere depuis le label (JS + fallback serveur)
- Blocage suppression du dernier administrateur
- user_capabilities table ajoutee en DB
- Navbar : dropdown unique (nom + Mon identite + Administration + Deconnexion)
- SSO callback : preserve le nom personnalise, ne l ecrase plus a la connexion
- Grille articles : CSS Grid auto-fill full-width, hauteur uniforme par ligne
- CSP : add_files.js et post_confirm.js externalises
This commit is contained in:
Cedric Abonnel
2026-05-12 15:51:06 +02:00
parent 5275edfd20
commit 1d2e3d9a24
15 changed files with 1029 additions and 332 deletions
+2 -1
View File
@@ -30,8 +30,9 @@ RewriteRule ^diff/([0-9a-f-]{36})/(\d+)/?$ /index.php?action=diff&uuid=$1&rev=$2
RewriteRule ^files/([0-9a-f-]{36})/add/?$ /index.php?action=add_files&uuid=$1 [L,QSA] RewriteRule ^files/([0-9a-f-]{36})/add/?$ /index.php?action=add_files&uuid=$1 [L,QSA]
RewriteRule ^import/([0-9a-f-]{36})/?$ /index.php?action=import_image&uuid=$1 [L,QSA] RewriteRule ^import/([0-9a-f-]{36})/?$ /index.php?action=import_image&uuid=$1 [L,QSA]
# Admin (regen-thumbs avant la règle générique admin/<tab>) # Admin (regen-thumbs et role/<email> avant la règle générique admin/<tab>)
RewriteRule ^admin/regen-thumbs/?$ /index.php?action=regen_thumbs [L,QSA] RewriteRule ^admin/regen-thumbs/?$ /index.php?action=regen_thumbs [L,QSA]
RewriteRule ^admin/role/([a-z0-9_-]+)/?$ /index.php?action=admin_role_edit&role_name=$1 [L,QSA]
RewriteRule ^admin/([a-z0-9-]+)/?$ /index.php?action=admin&tab=$1 [L,QSA] RewriteRule ^admin/([a-z0-9-]+)/?$ /index.php?action=admin&tab=$1 [L,QSA]
RewriteRule ^admin/?$ /index.php?action=admin [L,QSA] RewriteRule ^admin/?$ /index.php?action=admin [L,QSA]
+7
View File
@@ -816,6 +816,13 @@ textarea.form-control {
margin: 2rem 0; margin: 2rem 0;
} }
/* ─── Post grid — auto-fill columns ─────────── */
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
/* ─── Post list — date / meta ─────────────── */ /* ─── Post list — date / meta ─────────────── */
.post-meta { .post-meta {
font-size: 0.8rem; font-size: 0.8rem;
+118
View File
@@ -0,0 +1,118 @@
document.addEventListener('DOMContentLoaded', function () {
var panel = document.getElementById('sf-panel');
if (!panel) return;
var input = document.getElementById('sf-input');
var btn = document.getElementById('sf-btn');
var box = document.getElementById('sf-results');
var toUuid = panel.dataset.uuid;
function fileIcon(mime) {
if (mime.startsWith('video/')) return '🎬';
if (mime.startsWith('audio/')) return '🎵';
if (mime === 'application/pdf') return '📑';
return '📄';
}
async function doSearch() {
var q = input.value.trim();
if (!q) return;
btn.disabled = true;
box.innerHTML = '<p class="text-muted small">Recherche…</p>';
try {
var res = await fetch('/?action=search_files&q=' + encodeURIComponent(q) + '&exclude=' + encodeURIComponent(toUuid));
var data = await res.json();
box.innerHTML = '';
if (!data.length) {
box.innerHTML = '<p class="text-muted small">Aucun fichier trouvé.</p>';
return;
}
data.forEach(function (group) {
var section = document.createElement('div');
section.className = 'mb-4';
var header = document.createElement('p');
header.className = 'fw-semibold small mb-2';
header.textContent = group.article.title;
section.appendChild(header);
var grid = document.createElement('div');
grid.className = 'd-flex flex-wrap gap-2';
group.files.forEach(function (f) {
var wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;cursor:pointer';
wrap.title = f.name + ' (' + (f.size / 1024).toFixed(1) + ' Ko)';
if (f.is_image) {
var img = document.createElement('img');
img.src = f.url;
img.alt = f.name;
img.style.cssText = 'width:72px;height:72px;object-fit:cover;border-radius:6px;border:2px solid transparent;transition:border-color .15s,opacity .15s;display:block';
wrap.appendChild(img);
} else {
var icon = document.createElement('div');
icon.style.cssText = 'width:72px;height:72px;border-radius:6px;border:2px solid #dee2e6;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:1.6rem;background:#f8f9fa;transition:border-color .15s';
icon.innerHTML = fileIcon(f.mime) + '<span style="font-size:.6rem;margin-top:2px;color:#6c757d;max-width:68px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + f.name.split('.').pop().toUpperCase() + '</span>';
wrap.appendChild(icon);
}
var overlay = document.createElement('div');
overlay.style.cssText = 'position:absolute;inset:0;border-radius:6px;display:none;align-items:center;justify-content:center;background:rgba(25,135,84,.8);color:#fff;font-size:1.4rem';
overlay.textContent = '✓';
wrap.appendChild(overlay);
wrap.addEventListener('mouseenter', function () {
if (!wrap._copied) wrap.firstChild.style.borderColor = '#0d6efd';
});
wrap.addEventListener('mouseleave', function () {
if (!wrap._copied) wrap.firstChild.style.borderColor = 'transparent';
});
wrap.addEventListener('click', async function () {
if (wrap._copying || wrap._copied) return;
wrap._copying = true;
wrap.firstChild.style.opacity = '.5';
try {
var fd = new FormData();
fd.append('from_uuid', group.article.uuid);
fd.append('name', f.name);
fd.append('to_uuid', toUuid);
var r = await fetch('/?action=copy_file&uuid=' + encodeURIComponent(toUuid), {method: 'POST', body: fd});
var d = await r.json();
if (d.ok) {
wrap._copied = true;
wrap.firstChild.style.opacity = '1';
wrap.firstChild.style.borderColor = '#198754';
overlay.style.display = 'flex';
} else {
wrap.firstChild.style.opacity = '1';
wrap.firstChild.style.borderColor = '#dc3545';
wrap.title = d.error || 'Erreur';
}
} catch (e) {
wrap.firstChild.style.opacity = '1';
} finally {
wrap._copying = false;
}
});
grid.appendChild(wrap);
});
section.appendChild(grid);
box.appendChild(section);
});
} catch (e) {
box.innerHTML = '<p class="text-danger small">Erreur de recherche.</p>';
} finally {
btn.disabled = false;
}
}
btn.addEventListener('click', doSearch);
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
});
doSearch();
});
+19
View File
@@ -50,6 +50,25 @@ document.addEventListener('DOMContentLoaded', function () {
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
} }
// ─── Rôle : nom technique auto depuis le label ───────────────────────────
var roleLabelInput = document.getElementById('role-label');
var roleNameInput = document.getElementById('role-name');
if (roleLabelInput && roleNameInput) {
roleLabelInput.addEventListener('input', function () {
if (roleNameInput._manual) return;
roleNameInput.value = slugify(this.value);
});
roleNameInput.addEventListener('input', function () {
this._manual = (this.value !== '');
});
roleNameInput.addEventListener('blur', function () {
if (this.value === '') {
this._manual = false;
this.value = slugify(roleLabelInput.value);
}
});
}
// ─── Aperçu couleur catégorie ──────────────────────────────────────────── // ─── Aperçu couleur catégorie ────────────────────────────────────────────
const KNOWN_CATS = { const KNOWN_CATS = {
'actualité': 10, 'travaux': 35, 'scolaire': 55, 'actualité': 10, 'travaux': 35, 'scolaire': 55,
+62
View File
@@ -0,0 +1,62 @@
document.addEventListener('DOMContentLoaded', function () {
var data = document.getElementById('pc-data');
if (!data) return;
var defaultTitle = data.dataset.defaultTitle;
var defaultDesc = data.dataset.defaultDesc;
var baseUrl = data.dataset.baseUrl;
function initCounter(inputId, counterId, max) {
var el = document.getElementById(inputId);
var ct = document.getElementById(counterId);
if (!el || !ct) return;
function upd() {
var n = el.value.length;
ct.textContent = n + ' / ' + max;
ct.className = n > max ? 'text-danger' : 'text-muted';
}
el.addEventListener('input', upd);
upd();
}
initCounter('seo_title', 'seo_title_counter', 60);
initCounter('seo_description', 'seo_desc_counter', 155);
function updatePreview() {
var seoTitle = document.getElementById('seo_title').value.trim();
var seoDesc = document.getElementById('seo_description').value.trim();
var slug = document.getElementById('confirm-slug').value.trim();
document.getElementById('preview-title').textContent = seoTitle || defaultTitle;
document.getElementById('preview-desc').textContent = seoDesc || defaultDesc;
document.getElementById('preview-url').textContent = baseUrl + slug;
}
['seo_title', 'seo_description', 'confirm-slug'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener('input', updatePreview);
});
var slugInput = document.getElementById('confirm-slug');
var slugDisplay = document.getElementById('slug-display');
var btnSuggest = document.getElementById('slug-btn-suggest');
if (btnSuggest) {
btnSuggest.addEventListener('click', function () {
var val = btnSuggest.dataset.slugSuggest;
slugInput.value = val;
slugDisplay.textContent = val;
updatePreview();
});
}
var btnKeep = document.getElementById('slug-btn-keep');
if (btnKeep) {
btnKeep.addEventListener('click', function () {
var val = btnKeep.dataset.slugKeep;
slugInput.value = val;
slugDisplay.textContent = val;
updatePreview();
});
}
updatePreview();
});
+218 -8
View File
@@ -603,6 +603,12 @@ switch ($action) {
$errors[] = 'Le titre est obligatoire.'; $errors[] = 'Le titre est obligatoire.';
} }
if (empty($errors)) { if (empty($errors)) {
if (!empty($_POST['_confirm'])) {
$coverFile = trim($_POST['cover_file'] ?? '') ?: ($article['cover'] ?? '');
$ogImageFromCover = $coverFile !== ''
? rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFile)
: '';
$articles->update( $articles->update(
$uuid, $uuid,
$title, $title,
@@ -613,11 +619,10 @@ switch ($action) {
$_POST['revision_comment'] ?? '', $_POST['revision_comment'] ?? '',
$_POST['seo_title'] ?? '', $_POST['seo_title'] ?? '',
$_POST['seo_description'] ?? '', $_POST['seo_description'] ?? '',
$_POST['og_image'] ?? '', $ogImageFromCover,
$_POST['category'] ?? '' $_POST['category'] ?? ''
); );
// Métadonnées des fichiers existants (auteur, source)
$fmetaNames = $_POST['fmeta_name'] ?? []; $fmetaNames = $_POST['fmeta_name'] ?? [];
$fmetaAuthors = $_POST['fmeta_author'] ?? []; $fmetaAuthors = $_POST['fmeta_author'] ?? [];
$fmetaSources = $_POST['fmeta_source'] ?? []; $fmetaSources = $_POST['fmeta_source'] ?? [];
@@ -625,7 +630,6 @@ switch ($action) {
$articles->addFileMeta($uuid, $fname, trim($fmetaAuthors[$fi] ?? ''), trim($fmetaSources[$fi] ?? '')); $articles->addFileMeta($uuid, $fname, trim($fmetaAuthors[$fi] ?? ''), trim($fmetaSources[$fi] ?? ''));
} }
// Cover
$coverFile = trim($_POST['cover_file'] ?? ''); $coverFile = trim($_POST['cover_file'] ?? '');
if ($coverFile !== '') { if ($coverFile !== '') {
$articles->setCover($uuid, $coverFile); $articles->setCover($uuid, $coverFile);
@@ -635,6 +639,54 @@ switch ($action) {
header('Location: /post/' . rawurlencode($updated['slug'] ?? $uuid)); header('Location: /post/' . rawurlencode($updated['slug'] ?? $uuid));
exit; exit;
} }
// ─── Page de confirmation ────────────────────────────────────
$diffLines = lineDiff((string)($article['content'] ?? ''), $content);
$titleChanged = ($title !== ($article['title'] ?? ''));
$autoSlug = slugify($title);
$changes = [];
if ($titleChanged) {
$changes[] = 'titre modifié';
}
if (($category ?? '') !== ($article['category'] ?? '')) {
$changes[] = 'catégorie modifiée';
}
if ($content !== ($article['content'] ?? '')) {
$changes[] = 'contenu modifié';
}
$oldPublished = (bool)($article['published'] ?? false);
if ($published !== $oldPublished) {
$changes[] = $published ? 'article publié' : 'article dépublié';
}
$newCover = trim($_POST['cover_file'] ?? '');
if ($newCover !== '' && $newCover !== ($article['cover'] ?? '')) {
$changes[] = 'couverture modifiée';
}
$fmetaNames = $_POST['fmeta_name'] ?? [];
$fmetaAuthors = $_POST['fmeta_author'] ?? [];
$fmetaSources = $_POST['fmeta_source'] ?? [];
foreach ($fmetaNames as $fi => $fname) {
$savedMeta = ($article['files_meta'][$fname] ?? []);
if (trim($fmetaAuthors[$fi] ?? '') !== ($savedMeta['author'] ?? '')
|| trim($fmetaSources[$fi] ?? '') !== ($savedMeta['source_url'] ?? '')) {
$changes[] = 'métadonnées fichiers modifiées';
break;
}
}
$autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : '';
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(
trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text($content)))),
0, 155, '…'
);
unset($_pd);
include BASE_PATH . '/templates/post_confirm.php';
exit;
}
} }
$formAction = '/edit/' . rawurlencode($uuid); $formAction = '/edit/' . rawurlencode($uuid);
@@ -795,6 +847,36 @@ switch ($action) {
echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]); echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]);
exit; exit;
case 'copy_file':
requireAuth();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false]);
exit;
}
$cfFrom = trim($_POST['from_uuid'] ?? '');
$cfTo = $uuid !== '' ? $uuid : trim($_POST['to_uuid'] ?? '');
$cfName = basename($_POST['name'] ?? '');
if (!preg_match('/^[0-9a-f-]{36}$/', $cfFrom)
|| !preg_match('/^[0-9a-f-]{36}$/', $cfTo)
|| $cfName === ''
|| str_starts_with($cfName, '.')) {
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']);
exit;
}
$cfSrc = BASE_PATH . '/data/' . $cfFrom . '/files/' . $cfName;
$cfDstDir = BASE_PATH . '/data/' . $cfTo . '/files';
$cfDst = $cfDstDir . '/' . $cfName;
if (!file_exists($cfSrc)) {
echo json_encode(['ok' => false, 'error' => 'Fichier source introuvable']);
exit;
}
if (!is_dir($cfDstDir)) {
mkdir($cfDstDir, 0775, true);
}
echo json_encode(['ok' => copy($cfSrc, $cfDst)]);
exit;
case 'add_files': case 'add_files':
requireAuth(); requireAuth();
$addFilesArticle = $articles->getByUuid($uuid); $addFilesArticle = $articles->getByUuid($uuid);
@@ -1305,7 +1387,6 @@ switch ($action) {
ORDER BY r.name' ORDER BY r.name'
); );
$roles = $st->fetchAll(PDO::FETCH_ASSOC); $roles = $st->fetchAll(PDO::FETCH_ASSOC);
// Charge les capacités par rôle
try { try {
$capRows = $pdo->query('SELECT role_id, capability FROM role_capabilities')->fetchAll(PDO::FETCH_ASSOC); $capRows = $pdo->query('SELECT role_id, capability FROM role_capabilities')->fetchAll(PDO::FETCH_ASSOC);
$capsMap = []; $capsMap = [];
@@ -1344,10 +1425,10 @@ switch ($action) {
// table absente, on continue avec la liste user_roles seulement // table absente, on continue avec la liste user_roles seulement
} }
$st = $pdo->query('SELECT ur.user_email, r.name FROM user_roles ur JOIN roles r ON r.id = ur.role_id ORDER BY ur.user_email'); $st = $pdo->query('SELECT ur.user_email, r.name, r.label FROM user_roles ur JOIN roles r ON r.id = ur.role_id ORDER BY ur.user_email');
$rolesMap = []; $rolesMap = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$rolesMap[$row['user_email']][] = $row['name']; $rolesMap[$row['user_email']][] = ['name' => $row['name'], 'label' => $row['label']];
} }
$merged = []; $merged = [];
@@ -1355,7 +1436,7 @@ switch ($action) {
$merged[$email] = [ $merged[$email] = [
'email' => $email, 'email' => $email,
'is_active' => $usersFromDb[$email] ?? null, 'is_active' => $usersFromDb[$email] ?? null,
'roles' => $rolesMap[$email] ?? [], 'roles' => $rolesMap[$email] ?? [], // [['name'=>..., 'label'=>...], ...]
]; ];
} }
ksort($merged); ksort($merged);
@@ -1405,6 +1486,19 @@ switch ($action) {
if ($targetEmail && $roleName) { if ($targetEmail && $roleName) {
$pdo = dbPdo(); $pdo = dbPdo();
if ($pdo) { if ($pdo) {
// Bloquer si c'est le dernier admin (en DB — hors ADMIN_EMAIL env)
if ($roleName === 'admin') {
$st = $pdo->prepare(
'SELECT COUNT(*) FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE r.name = :role AND ur.user_email != :email'
);
$st->execute([':role' => 'admin', ':email' => $targetEmail]);
if ((int)$st->fetchColumn() === 0) {
header('Location: /admin/users?error=last_admin');
exit;
}
}
$st = $pdo->prepare( $st = $pdo->prepare(
'DELETE FROM user_roles 'DELETE FROM user_roles
WHERE user_email = :email WHERE user_email = :email
@@ -1422,8 +1516,11 @@ switch ($action) {
http_response_code(403); http_response_code(403);
exit; exit;
} }
$roleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_POST['name'] ?? '')));
$roleLabel = trim($_POST['label'] ?? ''); $roleLabel = trim($_POST['label'] ?? '');
$roleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_POST['name'] ?? '')));
if ($roleName === '' && $roleLabel !== '') {
$roleName = slugify($roleLabel);
}
if ($roleName && $roleLabel) { if ($roleName && $roleLabel) {
$pdo = dbPdo(); $pdo = dbPdo();
if ($pdo) { if ($pdo) {
@@ -1495,6 +1592,69 @@ switch ($action) {
header('Location: /admin/roles'); header('Location: /admin/roles');
exit; exit;
case 'admin_role_edit':
requireAuth();
if (!isAdmin()) {
http_response_code(403);
exit;
}
$editRoleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_GET['role_name'] ?? '')));
if (!$editRoleName) {
header('Location: /admin/roles');
exit;
}
$pdo = dbPdo();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($pdo) {
$newLabel = trim($_POST['label'] ?? '');
$newCaps = array_filter(
(array)($_POST['caps'] ?? []),
fn ($c) => array_key_exists($c, KNOWN_CAPABILITIES)
);
if ($newLabel) {
$pdo->prepare('UPDATE roles SET label = :l WHERE name = :n')
->execute([':l' => $newLabel, ':n' => $editRoleName]);
}
$st = $pdo->prepare('SELECT id FROM roles WHERE name = :n');
$st->execute([':n' => $editRoleName]);
$editRoleId = $st->fetchColumn();
if ($editRoleId) {
$pdo->prepare('DELETE FROM role_capabilities WHERE role_id = :id')
->execute([':id' => $editRoleId]);
$ins = $pdo->prepare('INSERT INTO role_capabilities (role_id, capability) VALUES (:id, :cap)');
foreach ($newCaps as $cap) {
$ins->execute([':id' => $editRoleId, ':cap' => $cap]);
}
}
unset($_SESSION['user_capabilities']);
}
header('Location: /admin/roles');
exit;
}
// GET — charge le rôle et ses capacités
$editRole = null;
$editRoleCaps = [];
if ($pdo) {
try {
$st = $pdo->prepare('SELECT id, name, label FROM roles WHERE name = :n');
$st->execute([':n' => $editRoleName]);
$editRole = $st->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (\Throwable) {}
if ($editRole) {
try {
$st = $pdo->prepare('SELECT capability FROM role_capabilities WHERE role_id = :id');
$st->execute([':id' => $editRole['id']]);
$editRoleCaps = $st->fetchAll(PDO::FETCH_COLUMN) ?: [];
} catch (\Throwable) {}
}
}
if (!$editRole) {
header('Location: /admin/roles');
exit;
}
include BASE_PATH . '/templates/admin_role_edit.php';
exit;
case 'profile': case 'profile':
requireAuth(); requireAuth();
$profileError = ''; $profileError = '';
@@ -1525,6 +1685,56 @@ switch ($action) {
include BASE_PATH . '/templates/profile.php'; include BASE_PATH . '/templates/profile.php';
break; break;
case 'search_files':
requireAuth();
header('Content-Type: application/json');
$q = trim($_GET['q'] ?? '');
$sfExclude = trim($_GET['exclude'] ?? '');
if ($q === '') {
echo json_encode([]);
exit;
}
require_once BASE_PATH . '/src/SearchEngine.php';
$sfPool = $articles->getSearchIndex() ?? $articles->getAll();
$sfResults = (new SearchEngine())->search($q, $sfPool);
$sfOut = [];
foreach ($sfResults as $r) {
$a = $r['article'];
$aId = $a['uuid'] ?? '';
if ($aId === '' || $aId === $sfExclude) {
continue;
}
$aFiles = $articles->getFiles($aId);
if (empty($aFiles)) {
continue;
}
$sfFiles = [];
foreach ($aFiles as $f) {
if (str_starts_with($f['name'], '_thumb_')) {
continue;
}
$sfFiles[] = [
'url' => '/file?uuid=' . rawurlencode($aId) . '&name=' . rawurlencode($f['name']),
'name' => $f['name'],
'mime' => $f['mime'],
'is_image' => $f['is_image'],
'size' => $f['size'],
];
}
if (empty($sfFiles)) {
continue;
}
$sfOut[] = [
'article' => ['uuid' => $aId, 'title' => $a['title'] ?? '', 'slug' => $a['slug'] ?? ''],
'files' => $sfFiles,
];
if (count($sfOut) >= 20) {
break;
}
}
echo json_encode($sfOut);
exit;
case 'search': case 'search':
require_once BASE_PATH . '/src/SearchEngine.php'; require_once BASE_PATH . '/src/SearchEngine.php';
$searchQuery = trim($_GET['q'] ?? ''); $searchQuery = trim($_GET['q'] ?? '');
+34 -22
View File
@@ -139,19 +139,48 @@ if (!$email) {
} }
// Nom d'affichage depuis les claims SSO // Nom d'affichage depuis les claims SSO
$displayName = ''; $ssoName = '';
if (!empty($claims['given_name']) || !empty($claims['family_name'])) { if (!empty($claims['given_name']) || !empty($claims['family_name'])) {
$displayName = trim(($claims['given_name'] ?? '') . ' ' . ($claims['family_name'] ?? '')); $ssoName = trim(($claims['given_name'] ?? '') . ' ' . ($claims['family_name'] ?? ''));
} elseif (!empty($claims['name'])) { } elseif (!empty($claims['name'])) {
$displayName = trim($claims['name']); $ssoName = trim($claims['name']);
} elseif (!empty($claims['preferred_username'])) { } elseif (!empty($claims['preferred_username'])) {
$displayName = trim($claims['preferred_username']); $ssoName = trim($claims['preferred_username']);
}
// Charge le nom personnalisé depuis la base (prioritaire sur le SSO)
require_once dirname(__DIR__, 2) . '/src/auth.php';
$pdo = dbPdo();
$dbName = '';
if ($pdo) {
try {
$st = $pdo->prepare('SELECT display_name FROM user_profiles WHERE email = :e');
$st->execute([':e' => strtolower(trim($email))]);
$dbName = (string)($st->fetchColumn() ?: '');
} catch (\Throwable) {}
}
if ($dbName !== '') {
// Nom personnalisé existant → on le conserve, le SSO ne l'écrase pas
$sessionName = $dbName;
} else {
// Première connexion → on persiste le nom SSO
$sessionName = $ssoName;
if ($ssoName !== '' && $pdo) {
try {
$pdo->prepare(
'INSERT INTO user_profiles (email, display_name, updated_at)
VALUES (:e, :n, now())
ON CONFLICT (email) DO NOTHING'
)->execute([':e' => strtolower(trim($email)), ':n' => $ssoName]);
} catch (\Throwable) {}
}
} }
// Ouvre la session authentifiée // Ouvre la session authentifiée
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['user_email'] = strtolower(trim($email)); $_SESSION['user_email'] = strtolower(trim($email));
$_SESSION['user_display_name'] = $displayName; $_SESSION['user_display_name'] = $sessionName;
$_SESSION['oidc'] = [ $_SESSION['oidc'] = [
'issuer' => $OIDC_ISSUER, 'issuer' => $OIDC_ISSUER,
'sub' => $claims['sub'] ?? null, 'sub' => $claims['sub'] ?? null,
@@ -160,23 +189,6 @@ $_SESSION['oidc'] = [
'expires_at' => time() + (int)($tokens['expires_in'] ?? 3600), 'expires_at' => time() + (int)($tokens['expires_in'] ?? 3600),
]; ];
// Persiste le nom d'affichage en base (seulement s'il vient du SSO et que la table existe)
if ($displayName !== '') {
require_once dirname(__DIR__, 2) . '/src/auth.php';
$pdo = dbPdo();
if ($pdo) {
try {
$st = $pdo->prepare(
'INSERT INTO user_profiles (email, display_name, updated_at)
VALUES (:e, :n, now())
ON CONFLICT (email) DO NOTHING'
);
$st->execute([':e' => strtolower(trim($email)), ':n' => $displayName]);
} catch (\Throwable) {
}
}
}
$target = $_SESSION['oidc_return_to'] ?? '/'; $target = $_SESSION['oidc_return_to'] ?? '/';
unset($_SESSION['oidc_return_to'], $_SESSION['oidc_flow']); unset($_SESSION['oidc_return_to'], $_SESSION['oidc_flow']);
if (!is_string($target) || $target === '' || $target[0] !== '/') { if (!is_string($target) || $target === '' || $target[0] !== '/') {
+29 -11
View File
@@ -119,22 +119,40 @@ function hasRole(string $role): bool
// Capacités connues — clé => label affiché dans l'admin // Capacités connues — clé => label affiché dans l'admin
const KNOWN_CAPABILITIES = [ const KNOWN_CAPABILITIES = [
'view_sources_own' => 'Voir les sources de ses propres articles', 'propose_articles' => 'Proposer des articles',
'view_sources_all' => 'Voir les sources de tous les articles', 'validate_articles_all' => 'Valider des articles',
'view_drafts_own' => 'Voir ses articles non publiés', 'validate_articles_own' => 'Valider ses articles uniquement',
'view_drafts_all' => 'Voir tous les articles non publiés', 'publish_articles_all' => 'Publier des articles',
'edit_articles_own' => 'Modifier ses propres articles', 'publish_articles_own' => 'Publier ses articles uniquement',
'edit_articles_all' => 'Modifier tous les articles', 'edit_articles_all' => 'Modifier des articles',
'edit_articles_own' => 'Modifier ses articles uniquement',
'rate_articles' => 'Noter des articles', 'rate_articles' => 'Noter des articles',
'view_previews' => 'Lire des avant-premières',
'view_drafts_all' => 'Voir tous les brouillons',
'view_drafts_own' => 'Voir ses brouillons',
'view_sources_all' => 'Voir les sources (tous les articles)',
'view_sources_own' => 'Voir les sources de ses articles',
]; ];
// Groupes pour l'interface d'administration // Groupes pour l'interface d'administration
// 'single' => pas de variante own/all
const CAPABILITY_GROUPS = [ const CAPABILITY_GROUPS = [
['label' => 'Sources & métadonnées', 'own' => 'view_sources_own', 'all' => 'view_sources_all'], 'Articles' => [
['label' => 'Articles non publiés', 'own' => 'view_drafts_own', 'all' => 'view_drafts_all'], 'propose_articles',
['label' => 'Modification', 'own' => 'edit_articles_own', 'all' => 'edit_articles_all'], 'validate_articles_all',
['label' => 'Noter des articles', 'single' => 'rate_articles'], 'validate_articles_own',
'publish_articles_all',
'publish_articles_own',
'edit_articles_all',
'edit_articles_own',
],
'Accès & lecture' => [
'rate_articles',
'view_previews',
'view_drafts_all',
'view_drafts_own',
'view_sources_all',
'view_sources_own',
],
]; ];
function currentUserCapabilities(): array function currentUserCapabilities(): array
+68 -31
View File
@@ -1,63 +1,78 @@
<?php <?php
ob_start(); ob_start();
$existingFiles = $articles->getFiles($addFilesArticle['uuid']); $existingFiles = $articles->getFiles($addFilesArticle['uuid']);
$articleUuid = $addFilesArticle['uuid'];
$articleTitle = $addFilesArticle['title'];
// Extraire 1-3 mots significatifs du titre pour l'auto-recherche
$_sfStop = ['ou','et','un','une','le','la','les','de','du','des','en','au','aux','ce','cet',
'cette','ces','que','qui','par','sur','dans','son','sa','ses','mon','ton','nos',
'vos','leur','leurs','voir','comment','quoi','dont','votre','notre','selon','car',
'mais','donc','puis','plus','très','avec','pour','pas','est','sont','était',
'être','avoir','faire','tout','tous','toute','toutes'];
$_sfWords = preg_split('/[^a-zA-ZÀ-ÿ0-9]+/u', $articleTitle) ?: [];
$_sfKw = [];
foreach ($_sfWords as $_w) {
if (mb_strlen($_w) >= 3 && !in_array(mb_strtolower($_w), $_sfStop, true)) {
$_sfKw[] = $_w;
if (count($_sfKw) >= 3) {
break;
}
}
}
$autoSearchQuery = !empty($_sfKw) ? implode(' ', $_sfKw) : $articleTitle;
unset($_sfStop, $_sfWords, $_sfKw, $_w);
?> ?>
<div class="d-flex align-items-center gap-3 mb-4"> <div class="d-flex align-items-center gap-3 mb-4">
<a href="/edit/<?= rawurlencode($addFilesArticle['uuid']) ?>" class="btn btn-secondary btn-sm">← Retour</a> <a href="/edit/<?= rawurlencode($articleUuid) ?>" class="btn btn-secondary btn-sm">← Retour</a>
<h1 class="h4 mb-0">Ajouter des fichiers</h1> <h1 class="h4 mb-0">Ajouter des fichiers</h1>
</div> </div>
<p class="text-muted small mb-4"> <p class="text-muted small mb-4">
Article : <strong><?= htmlspecialchars($addFilesArticle['title']) ?></strong> Article : <strong><?= htmlspecialchars($articleTitle) ?></strong>
</p> </p>
<div class="row g-4"> <div class="row g-4">
<!-- Formulaire d'upload --> <!-- Upload -->
<div class="col-lg-5"> <div class="col-lg-4">
<div class="card"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5 class="card-title h6 mb-3">Uploader</h5>
<form method="POST" <form method="POST"
action="/files/<?= rawurlencode($addFilesArticle['uuid']) ?>/add" action="/files/<?= rawurlencode($articleUuid) ?>/add"
enctype="multipart/form-data"> enctype="multipart/form-data">
<div class="mb-3"> <div class="mb-3">
<label for="files" class="form-label fw-semibold">Fichiers à uploader</label> <input type="file" class="form-control" id="files" name="files[]" multiple required>
<input type="file" class="form-control" id="files" name="files[]"
multiple required>
<div class="form-text"> <div class="form-text">
Images → nommées <code>sha256-taille.ext</code><br> Images → <code>sha256-taille.ext</code><br>
Vidéos, PDF, autres → nom sanitisé Vidéos, PDF, autres → nom sanitisé
</div> </div>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Uploader</button> <button type="submit" class="btn btn-primary btn-sm">Uploader</button>
<a href="/edit/<?= rawurlencode($addFilesArticle['uuid']) ?>" <a href="/edit/<?= rawurlencode($articleUuid) ?>" class="btn btn-outline-secondary btn-sm">Annuler</a>
class="btn btn-outline-secondary">Annuler</a>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div>
<!-- Fichiers déjà présents --> <!-- Fichiers déjà présents -->
<?php if ($existingFiles): ?> <?php if ($existingFiles): ?>
<div class="col-lg-7"> <div class="card">
<h5 class="mb-3">Fichiers existants</h5> <div class="card-body">
<div class="list-group"> <h5 class="card-title h6 mb-3">Fichiers existants</h5>
<?php foreach ($existingFiles as $f): ?> <div class="list-group list-group-flush">
<?php $fileUrl = '/file?uuid=' . rawurlencode($addFilesArticle['uuid']) . '&name=' . rawurlencode($f['name']); ?> <?php foreach ($existingFiles as $f):
<div class="list-group-item d-flex align-items-center gap-3 py-2"> $fileUrl = '/file?uuid=' . rawurlencode($articleUuid) . '&name=' . rawurlencode($f['name']);
?>
<div class="list-group-item d-flex align-items-center gap-2 px-0 py-1">
<?php if ($f['is_image']): ?> <?php if ($f['is_image']): ?>
<a href="<?= htmlspecialchars($fileUrl) ?>" target="_blank" rel="noopener" class="flex-shrink-0">
<img src="<?= htmlspecialchars($fileUrl) ?>" alt="" <img src="<?= htmlspecialchars($fileUrl) ?>" alt=""
style="width:48px;height:48px;object-fit:cover;border-radius:4px"> style="width:40px;height:40px;object-fit:cover;border-radius:4px;flex-shrink:0">
</a>
<?php else: ?> <?php else: ?>
<span style="width:48px;text-align:center;font-size:1.5rem;flex-shrink:0"> <span style="width:40px;text-align:center;font-size:1.3rem;flex-shrink:0">
<?= match(true) { <?= match(true) {
str_starts_with($f['mime'], 'video/') => '🎬', str_starts_with($f['mime'], 'video/') => '🎬',
str_starts_with($f['mime'], 'audio/') => '🎵', str_starts_with($f['mime'], 'audio/') => '🎵',
@@ -66,22 +81,44 @@ $existingFiles = $articles->getFiles($addFilesArticle['uuid']);
} ?> } ?>
</span> </span>
<?php endif; ?> <?php endif; ?>
<div class="flex-grow-1 overflow-hidden"> <div class="overflow-hidden" style="min-width:0">
<code class="d-block small text-truncate"><?= htmlspecialchars($f['name']) ?></code> <code class="d-block small text-truncate"><?= htmlspecialchars($f['name']) ?></code>
<small class="text-muted"><?= number_format($f['size'] / 1024, 1) ?> Ko</small> <small class="text-muted"><?= number_format($f['size'] / 1024, 1) ?> Ko</small>
</div> </div>
<?php if ($addFilesArticle['cover'] ?? '' === $f['name']): ?> <?php if (($addFilesArticle['cover'] ?? '') === $f['name']): ?>
<span class="badge bg-primary flex-shrink-0">cover</span> <span class="badge bg-primary ms-auto flex-shrink-0">cover</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
</div>
<?php endif; ?> <?php endif; ?>
</div>
<!-- Recherche dans les autres articles -->
<div class="col-lg-8">
<div id="sf-panel" data-uuid="<?= htmlspecialchars($articleUuid) ?>" class="card">
<div class="card-body">
<h5 class="card-title h6 mb-3">Fichiers d'autres articles</h5>
<div class="d-flex gap-2 mb-3">
<input type="text" id="sf-input" class="form-control form-control-sm"
value="<?= htmlspecialchars($autoSearchQuery) ?>"
placeholder="Titre, mot-clé…" autocomplete="off">
<button type="button" id="sf-btn" class="btn btn-sm btn-outline-secondary text-nowrap">
Chercher
</button>
</div>
<div id="sf-results"></div>
</div>
</div>
</div>
</div> </div>
<script src="/assets/js/add_files.js"></script>
<?php <?php
$content = ob_get_clean(); $content = ob_get_clean();
$title = 'Ajouter des fichiers — ' . htmlspecialchars($addFilesArticle['title']); $title = 'Ajouter des fichiers — ' . htmlspecialchars($articleTitle);
include __DIR__ . '/layout.php'; include __DIR__ . '/layout.php';
+42 -106
View File
@@ -144,6 +144,12 @@ function adminStatusBadge(array $a, int $now): string
<!-- ─────────────────────────── UTILISATEURS ─────────────────────────── --> <!-- ─────────────────────────── UTILISATEURS ─────────────────────────── -->
<?php elseif ($tab === 'users' && isAdmin()): ?> <?php elseif ($tab === 'users' && isAdmin()): ?>
<?php if (($_GET['error'] ?? '') === 'last_admin'): ?>
<div class="alert alert-danger py-2 mb-3">
Impossible de retirer le rôle Administrateur : il doit rester au moins un administrateur.
</div>
<?php endif; ?>
<!-- Ajouter / attribuer un rôle --> <!-- Ajouter / attribuer un rôle -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header">Attribuer un rôle</div> <div class="card-header">Attribuer un rôle</div>
@@ -198,13 +204,13 @@ function adminStatusBadge(array $a, int $now): string
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<?php foreach ($u['roles'] as $roleName): ?> <?php foreach ($u['roles'] as $role): ?>
<span class="badge bg-primary me-1"><?= htmlspecialchars($roleName) ?></span> <span class="badge bg-primary me-1"><?= htmlspecialchars($role['label']) ?></span>
<form method="post" action="/?action=admin_revoke_role" <form method="post" action="/?action=admin_revoke_role"
class="d-inline" class="d-inline"
data-confirm="Retirer le rôle <?= htmlspecialchars($roleName) ?> à <?= htmlspecialchars($u['email']) ?> ?"> data-confirm="Retirer le rôle «<?= htmlspecialchars($role['label']) ?>» à <?= htmlspecialchars($u['email']) ?> ?">
<input type="hidden" name="email" value="<?= htmlspecialchars($u['email']) ?>"> <input type="hidden" name="email" value="<?= htmlspecialchars($u['email']) ?>">
<input type="hidden" name="role" value="<?= htmlspecialchars($roleName) ?>"> <input type="hidden" name="role" value="<?= htmlspecialchars($role['name']) ?>">
<button type="submit" class="btn btn-link btn-sm p-0 text-danger" title="Retirer">×</button> <button type="submit" class="btn btn-link btn-sm p-0 text-danger" title="Retirer">×</button>
</form> </form>
<?php endforeach; ?> <?php endforeach; ?>
@@ -215,8 +221,8 @@ function adminStatusBadge(array $a, int $now): string
<td class="text-end"> <td class="text-end">
<!-- Ajout rapide d'un rôle existant --> <!-- Ajout rapide d'un rôle existant -->
<?php <?php
$currentRoles = $u['roles']; $currentRoleNames = array_column($u['roles'], 'name');
$missing = array_filter($adminData['roles'], fn ($r) => !in_array($r['name'], $currentRoles, true)); $missing = array_filter($adminData['roles'], fn ($r) => !in_array($r['name'], $currentRoleNames, true));
?> ?>
<?php if ($missing): ?> <?php if ($missing): ?>
<form method="post" action="/?action=admin_grant_role" class="d-inline-flex gap-1"> <form method="post" action="/?action=admin_grant_role" class="d-inline-flex gap-1">
@@ -243,48 +249,51 @@ function adminStatusBadge(array $a, int $now): string
<div class="row g-4"> <div class="row g-4">
<!-- Liste des rôles existants --> <!-- Tableau des rôles existants -->
<div class="col-lg-8"> <div class="col-lg-8">
<h5 class="mb-3">Rôles existants</h5>
<?php if (empty($adminData['roles'])): ?> <?php if (empty($adminData['roles'])): ?>
<p class="text-muted">Aucun rôle défini.</p> <p class="text-muted">Aucun rôle défini.</p>
<?php else: ?> <?php else: ?>
<table class="table table-sm table-hover align-middle"> <table class="table table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th style="width:160px">Nom technique</th> <th>Rôle</th>
<th>Label affiché</th> <th class="text-center" style="width:100px">Utilisateurs</th>
<th class="text-center" style="width:90px">Utilisateurs</th> <th style="width:110px"></th>
<th style="width:100px"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($adminData['roles'] as $r): ?> <?php foreach ($adminData['roles'] as $r): ?>
<tr> <tr>
<td><code class="text-body"><?= htmlspecialchars($r['name']) ?></code></td>
<td> <td>
<form method="post" action="/?action=admin_update_role" <?= htmlspecialchars($r['label']) ?>
class="d-flex gap-2 align-items-center"> <?php if ($r['name'] !== 'admin'): ?>
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>"> <div class="text-muted small">
<input type="text" name="label" <?php
value="<?= htmlspecialchars($r['label']) ?>" $capLabels = array_map(
class="form-control form-control-sm" required> fn ($c) => KNOWN_CAPABILITIES[$c] ?? $c,
<button type="submit" class="btn btn-outline-secondary btn-sm text-nowrap"> $r['capabilities']
Sauver );
</button> echo htmlspecialchars(implode(', ', $capLabels) ?: '');
</form> ?>
</div>
<?php else: ?>
<div class="text-muted small">Toutes les permissions</div>
<?php endif; ?>
</td> </td>
<td class="text-center"> <td class="text-center">
<span class="badge bg-secondary"><?= (int)$r['user_count'] ?></span> <span class="badge bg-secondary"><?= (int)$r['user_count'] ?></span>
</td> </td>
<td class="text-end"> <td class="text-end d-flex gap-1 justify-content-end">
<a href="/admin/role/<?= rawurlencode($r['name']) ?>"
class="btn btn-outline-secondary btn-sm">Éditer</a>
<?php if ((int)$r['user_count'] === 0 && $r['name'] !== 'admin'): ?>
<form method="post" action="/?action=admin_delete_role" <form method="post" action="/?action=admin_delete_role"
data-confirm="Supprimer le rôle «<?= htmlspecialchars($r['name']) ?>» ?<?= (int)$r['user_count'] > 0 ? ' ' . (int)$r['user_count'] . ' utilisateur(s) perdront ce rôle.' : '' ?>"> data-confirm="Supprimer le rôle «<?= htmlspecialchars($r['name']) ?>» ?">
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>"> <input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
<button type="submit" class="btn btn-outline-danger btn-sm"> <button type="submit" class="btn btn-outline-danger btn-sm">×</button>
Supprimer
</button>
</form> </form>
<?php endif; ?>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
@@ -300,90 +309,17 @@ function adminStatusBadge(array $a, int $now): string
<div class="card-body"> <div class="card-body">
<form method="post" action="/?action=admin_create_role"> <form method="post" action="/?action=admin_create_role">
<div class="mb-3"> <div class="mb-3">
<label class="form-label small fw-semibold">Nom technique</label> <label for="role-label" class="form-label small fw-semibold">Nom du rôle</label>
<input type="text" name="name" class="form-control form-control-sm" <input type="text" id="role-label" name="label" class="form-control form-control-sm"
placeholder="ex : moderator" placeholder="ex : Modérateur" required autocomplete="off">
pattern="[a-z0-9_-]+"
title="Lettres minuscules, chiffres, tirets et underscores uniquement"
required>
<div class="form-text">Utilisé dans le code — ne change pas.</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Label affiché</label>
<input type="text" name="label" class="form-control form-control-sm"
placeholder="ex : Modérateur" required>
</div> </div>
<input type="hidden" id="role-name" name="name">
<button type="submit" class="btn btn-primary btn-sm w-100">Créer</button> <button type="submit" class="btn btn-primary btn-sm w-100">Créer</button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<!-- Permissions par rôle -->
<?php if (!empty($adminData['roles'])): ?>
<div class="col-12 mt-2">
<h5 class="mb-3">Permissions par rôle</h5>
<p class="text-muted small mb-3">Le rôle <code>admin</code> a toutes les permissions implicitement.</p>
<div class="row g-3">
<?php foreach ($adminData['roles'] as $r):
if ($r['name'] === 'admin') {
continue;
} ?>
<div class="col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-header py-2 d-flex align-items-center justify-content-between">
<span class="fw-semibold"><?= htmlspecialchars($r['label']) ?></span>
<code class="text-muted small"><?= htmlspecialchars($r['name']) ?></code>
</div>
<div class="card-body py-3">
<form method="post" action="/?action=admin_update_role_caps">
<input type="hidden" name="role_id" value="<?= (int)$r['id'] ?>">
<?php foreach (CAPABILITY_GROUPS as $group): ?>
<?php if (isset($group['single'])): ?>
<?php $cap = $group['single']; ?>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox"
name="caps[]" value="<?= htmlspecialchars($cap) ?>"
id="cap_<?= (int)$r['id'] ?>_<?= htmlspecialchars($cap) ?>"
<?= in_array($cap, $r['capabilities'], true) ? 'checked' : '' ?>>
<label class="form-check-label small fw-semibold"
for="cap_<?= (int)$r['id'] ?>_<?= htmlspecialchars($cap) ?>">
<?= htmlspecialchars($group['label']) ?>
</label>
</div>
<?php else: ?>
<div class="mb-3">
<div class="small fw-semibold mb-1"><?= htmlspecialchars($group['label']) ?></div>
<div class="d-flex gap-3 ps-1">
<?php foreach (['own' => 'Propres articles', 'all' => 'Tous'] as $scope => $scopeLabel):
$cap = $group[$scope]; ?>
<div class="form-check">
<input class="form-check-input" type="checkbox"
name="caps[]" value="<?= htmlspecialchars($cap) ?>"
id="cap_<?= (int)$r['id'] ?>_<?= htmlspecialchars($cap) ?>"
<?= in_array($cap, $r['capabilities'], true) ? 'checked' : '' ?>>
<label class="form-check-label small"
for="cap_<?= (int)$r['id'] ?>_<?= htmlspecialchars($cap) ?>">
<?= $scopeLabel ?>
</label>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
<button type="submit" class="btn btn-outline-secondary btn-sm mt-1 w-100">
Enregistrer
</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
+71
View File
@@ -0,0 +1,71 @@
<?php
ob_start();
$isAdminRole = ($editRole['name'] === 'admin');
?>
<div class="d-flex align-items-center gap-3 mb-4">
<a href="/admin/roles" class="btn btn-secondary btn-sm">← Retour</a>
<h1 class="h4 mb-0">Rôle : <?= htmlspecialchars($editRole['label']) ?></h1>
<code class="text-muted"><?= htmlspecialchars($editRole['name']) ?></code>
</div>
<form method="POST" action="/admin/role/<?= rawurlencode($editRole['name']) ?>">
<div class="row g-4">
<!-- Permissions -->
<div class="col-lg-8">
<?php if ($isAdminRole): ?>
<div class="alert alert-warning">
Le rôle <code>admin</code> a toutes les permissions implicitement — les cases à cocher sont ignorées.
</div>
<?php else: ?>
<div class="card">
<div class="card-body">
<?php foreach (CAPABILITY_GROUPS as $groupLabel => $groupCaps): ?>
<div class="mb-4">
<h6 class="fw-semibold text-muted text-uppercase small mb-3"><?= htmlspecialchars($groupLabel) ?></h6>
<?php foreach ($groupCaps as $cap): ?>
<?php if (!array_key_exists($cap, KNOWN_CAPABILITIES)) continue; ?>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox"
name="caps[]" value="<?= htmlspecialchars($cap) ?>"
id="cap_<?= htmlspecialchars($cap) ?>"
<?= in_array($cap, $editRoleCaps, true) ? 'checked' : '' ?>>
<label class="form-check-label" for="cap_<?= htmlspecialchars($cap) ?>">
<?= htmlspecialchars(KNOWN_CAPABILITIES[$cap]) ?>
</label>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<!-- Label + Actions -->
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-body">
<div class="mb-3">
<label for="role_label" class="form-label fw-semibold small">Label affiché</label>
<input type="text" id="role_label" name="label" class="form-control form-control-sm"
value="<?= htmlspecialchars($editRole['label']) ?>" required>
</div>
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
<a href="/admin/roles" class="btn btn-outline-secondary w-100 mt-2">Annuler</a>
</div>
</div>
</div>
</div>
</form>
<?php
$content = ob_get_clean();
$title = 'Rôle — ' . $editRole['label'];
include __DIR__ . '/layout.php';
+9 -8
View File
@@ -100,16 +100,17 @@
<ul class="navbar-nav"> <ul class="navbar-nav">
<?php if (function_exists('isLoggedIn') && isLoggedIn()): ?> <?php if (function_exists('isLoggedIn') && isLoggedIn()): ?>
<li class="nav-item"><a class="nav-link" href="/admin">Admin</a></li> <li class="nav-item dropdown">
<?php endif; ?> <a class="nav-link dropdown-toggle" href="#" role="button"
<?php if (function_exists('isLoggedIn') && isLoggedIn()): ?> data-bs-toggle="dropdown" aria-expanded="false">
<li class="nav-item">
<a class="nav-link" href="/profile">
<?= htmlspecialchars(function_exists('currentUserName') ? currentUserName() : (currentUserEmail() ?? '')) ?> <?= htmlspecialchars(function_exists('currentUserName') ? currentUserName() : (currentUserEmail() ?? '')) ?>
</a> </a>
</li> <ul class="dropdown-menu dropdown-menu-end">
<li class="nav-item"> <li><a class="dropdown-item" href="/profile">Mon identité</a></li>
<a class="nav-link text-muted" href="/logout.php" title="Déconnexion">Déconnexion</a> <li><a class="dropdown-item" href="/admin"><?= (function_exists('isAdmin') && isAdmin()) ? 'Administration' : 'Mes articles' ?></a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-muted" href="/logout.php">Déconnexion</a></li>
</ul>
</li> </li>
<?php else: ?> <?php else: ?>
<li class="nav-item"><a class="nav-link" href="/login">Connexion</a></li> <li class="nav-item"><a class="nav-link" href="/login">Connexion</a></li>
+258
View File
@@ -0,0 +1,258 @@
<?php
ob_start();
$CONTEXT = 3;
$base = rtrim(APP_URL, '/');
$effectiveTitle = ($seoTitle !== '') ? $seoTitle : $title;
$effectiveDesc = ($seoDescription !== '') ? $seoDescription : $autoSeoDesc;
$effectiveSlug = $postSlug;
$coverFilename = ($newCover ?? '') !== '' ? $newCover : ($article['cover'] ?? '');
$suggestedOgImage = $coverFilename !== ''
? $base . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFilename)
: '';
?>
<div class="d-flex align-items-center gap-3 mb-4 flex-wrap">
<a href="/edit/<?= htmlspecialchars($uuid) ?>" class="btn btn-outline-secondary">← Retour à l'édition</a>
<h1 class="h4 mb-0">Confirmer les modifications</h1>
</div>
<form method="POST" action="/edit/<?= htmlspecialchars($uuid) ?>">
<input type="hidden" name="_confirm" value="1">
<input type="hidden" name="title" value="<?= htmlspecialchars($title) ?>">
<input type="hidden" name="content" value="<?= htmlspecialchars($content) ?>">
<?php if ($published): ?>
<input type="hidden" name="published" value="1">
<?php endif; ?>
<input type="hidden" name="published_at" value="<?= htmlspecialchars($published_at) ?>">
<!-- seo_title et seo_description sont des champs visibles dans la colonne droite -->
<!-- og_image est calculé depuis la couverture côté serveur -->
<input type="hidden" name="category" value="<?= htmlspecialchars($category) ?>">
<?php if (($newCover ?? '') !== ''): ?>
<input type="hidden" name="cover_file" value="<?= htmlspecialchars($newCover) ?>">
<?php endif; ?>
<?php foreach ($fmetaNames as $fi => $fname): ?>
<input type="hidden" name="fmeta_name[]" value="<?= htmlspecialchars($fname) ?>">
<input type="hidden" name="fmeta_author[]" value="<?= htmlspecialchars($fmetaAuthors[$fi] ?? '') ?>">
<input type="hidden" name="fmeta_source[]" value="<?= htmlspecialchars($fmetaSources[$fi] ?? '') ?>">
<?php endforeach; ?>
<div class="row g-4">
<div class="col-lg-8">
<?php if (!empty($changes)): ?>
<div class="alert alert-info py-2 mb-3">
<?= htmlspecialchars(ucfirst(implode(' · ', $changes))) ?>
</div>
<?php else: ?>
<div class="alert alert-secondary py-2 mb-3">Aucune modification détectée.</div>
<?php endif; ?>
<!-- ─── Diff ─────────────────────────────────────────────────────────────── -->
<div class="mb-4">
<h2 class="h6 fw-semibold mb-2">Diff du contenu</h2>
<?php if ($diffLines === []): ?>
<div class="text-muted small">Contenu identique.</div>
<?php else:
$total = count($diffLines);
$show = [];
for ($i = 0; $i < $total; $i++) {
if ($diffLines[$i][0] !== '=') {
for ($c = max(0, $i - $CONTEXT); $c <= min($total - 1, $i + $CONTEXT); $c++) {
$show[$c] = true;
}
}
}
$hasChanges = false;
foreach ($diffLines as [$op]) { if ($op !== '=') { $hasChanges = true; break; } }
?>
<div class="d-flex gap-3 mb-1 small">
<span class="diff-del px-2 py-1 rounded"> Supprimé</span>
<span class="diff-ins px-2 py-1 rounded">+ Ajouté</span>
</div>
<div class="diff-view font-monospace small">
<?php $inEllipsis = false; for ($i = 0; $i < $total; $i++): ?>
<?php [$op, $line] = $diffLines[$i]; ?>
<?php if (!isset($show[$i])): ?>
<?php if (!$inEllipsis): $inEllipsis = true; ?>
<div class="diff-ellipsis text-muted px-2">⋯</div>
<?php endif; continue; ?>
<?php else: $inEllipsis = false; endif; ?>
<?php if ($op === '!'): ?>
<div class="diff-warning text-warning px-2"><?= htmlspecialchars($line) ?></div>
<?php elseif ($op === '-'): ?>
<div class="diff-del px-2">&nbsp;<?= htmlspecialchars($line) ?></div>
<?php elseif ($op === '+'): ?>
<div class="diff-ins px-2">+&nbsp;<?= htmlspecialchars($line) ?></div>
<?php else: ?>
<div class="diff-eq px-2 text-muted">&nbsp;&nbsp;<?= htmlspecialchars($line) ?></div>
<?php endif; ?>
<?php endfor; ?>
</div>
<?php endif; ?>
</div>
<!-- ─── Slug ─────────────────────────────────────────────────────────────── -->
<?php
$slugDefault = ($titleChanged && $autoSlug !== $postSlug) ? $autoSlug : $postSlug;
$slugOriginal = $postSlug;
?>
<div class="mb-3">
<label for="confirm-slug" class="form-label fw-semibold">
Slug (URL permanente)
<small class="text-muted fw-normal">— /post/<span id="slug-display"><?= htmlspecialchars($slugDefault) ?></span></small>
</label>
<input type="text" class="form-control form-control-sm font-monospace" id="confirm-slug" name="slug"
value="<?= htmlspecialchars($slugDefault) ?>"
pattern="[a-z0-9][a-z0-9\-]*"
oninput="document.getElementById('slug-display').textContent=this.value">
<?php if ($titleChanged && $autoSlug !== $slugOriginal): ?>
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
<small class="text-muted">Slug recalculé depuis le nouveau titre. Slug initial :</small>
<code class="small"><?= htmlspecialchars($slugOriginal) ?></code>
<button type="button" id="slug-btn-keep" class="btn btn-sm btn-outline-secondary py-0"
data-slug-keep="<?= htmlspecialchars($slugOriginal) ?>">
Conserver
</button>
</div>
<?php endif; ?>
</div>
<!-- ─── Commentaire de révision ──────────────────────────────────────────── -->
<div class="mb-4">
<label for="revision_comment" class="form-label fw-semibold">
Commentaire de révision <small class="text-muted fw-normal">(optionnel)</small>
</label>
<input type="text" class="form-control" id="revision_comment" name="revision_comment"
value="<?= htmlspecialchars($autoRevisionComment) ?>"
placeholder="ex. Correction typos, ajout section X…">
</div>
<div class="d-flex gap-3 flex-wrap">
<button type="submit" class="btn btn-success">Confirmer et enregistrer</button>
<a href="/edit/<?= htmlspecialchars($uuid) ?>" class="btn btn-outline-secondary">← Retour</a>
</div>
</div><!-- /col-lg-8 -->
<!-- ─── SEO ───────────────────────────────────────────────────────────────── -->
<div class="col-lg-4">
<div class="card border-secondary mb-3">
<div class="card-header bg-transparent py-2">
<span class="fw-semibold small">SEO — titre, description, image</span>
</div>
<div class="card-body">
<div class="mb-3">
<label for="seo_title" class="form-label small">
Titre SEO <small class="text-muted">(og:title, &lt;title&gt;)</small>
</label>
<input type="text" class="form-control form-control-sm" id="seo_title" name="seo_title"
maxlength="70"
value="<?= htmlspecialchars($seoTitle) ?>"
placeholder="<?= htmlspecialchars($title) ?>">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 3060 car.</small>
<small id="seo_title_counter" class="text-muted">0 / 60</small>
</div>
</div>
<div class="mb-3">
<label for="seo_description" class="form-label small">
Description SEO <small class="text-muted">(meta description)</small>
</label>
<textarea class="form-control form-control-sm" id="seo_description" name="seo_description"
rows="3" maxlength="200"
placeholder="<?= htmlspecialchars(mb_strimwidth($autoSeoDesc, 0, 80, '…')) ?>"><?= htmlspecialchars($seoDescription) ?></textarea>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 120155 car.</small>
<small id="seo_desc_counter" class="text-muted">0 / 155</small>
</div>
</div>
</div>
</div>
<div class="card border-secondary">
<div class="card-header bg-transparent py-2">
<span class="fw-semibold small">Aperçu dans les moteurs de recherche</span>
</div>
<div class="card-body p-3">
<?php
$pubTs = strtotime((string)($published_at ?? $article['published_at'] ?? ''));
$modTs = time(); // sera mis à jour à la sauvegarde
$pubFmt = $pubTs ? date('d/m/Y H:i', $pubTs) : '—';
$modFmt = date('d/m/Y H:i', $modTs);
$catVal = trim($category ?? '');
?>
<div class="seo-preview mb-3">
<div class="seo-preview-url small text-truncate mb-1" id="preview-url">
<?= htmlspecialchars(rtrim($base, '/') . '/post/' . $effectiveSlug) ?>
</div>
<div class="seo-preview-title mb-1" id="preview-title">
<?= htmlspecialchars($effectiveTitle) ?>
</div>
<div class="seo-preview-desc small" id="preview-desc">
<?= htmlspecialchars($effectiveDesc) ?>
</div>
</div>
<table class="table table-sm table-borderless mb-0 small">
<tbody>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap" style="width:1%">Auteur</th>
<td>Cédrix</td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Publication</th>
<td><?= htmlspecialchars($pubFmt) ?></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Modification</th>
<td><?= htmlspecialchars($modFmt) ?> <small class="text-muted">(après enreg.)</small></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Langue</th>
<td>fr-FR</td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Catégorie</th>
<td><?= $catVal !== '' ? htmlspecialchars($catVal) : '<span class="text-muted">—</span>' ?></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">og:image</th>
<td class="text-truncate" style="max-width:0">
<?= $suggestedOgImage !== '' ? '<span title="' . htmlspecialchars($suggestedOgImage) . '">' . htmlspecialchars($coverFilename) . '</span>' : '<span class="text-muted">— (pas de couverture)</span>' ?>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div><!-- /row -->
</form>
<style>
.diff-view { border: 1px solid var(--bs-border-color, #dee2e6); border-radius: 6px; overflow-x: auto; }
.diff-view > div { padding: 1px 8px; white-space: pre; line-height: 1.5; }
.diff-del { background: #ffeef0; color: #b91c1c; }
.diff-ins { background: #e6ffec; color: #15803d; }
.diff-eq { }
.diff-ellipsis { background: #f8f9fa; padding: 2px 8px; user-select: none; }
.seo-preview { border: 1px solid #dee2e6; border-radius: 6px; padding: 10px 12px; background: #fff; }
.seo-preview-url { color: #006621; font-size: .78rem; }
.seo-preview-title { color: #1a0dab; font-size: 1.05rem; font-weight: 500; line-height: 1.3; word-break: break-word; }
.seo-preview-desc { color: #545454; line-height: 1.5; }
</style>
<div id="pc-data" hidden
data-default-title="<?= htmlspecialchars($effectiveTitle) ?>"
data-default-desc="<?= htmlspecialchars($effectiveDesc) ?>"
data-base-url="<?= htmlspecialchars(rtrim($base, '/') . '/post/') ?>"></div>
<script src="/assets/js/post_confirm.js"></script>
<?php
$content = ob_get_clean();
$title = 'Confirmer — ' . $title;
include __DIR__ . '/layout.php';
+5 -57
View File
@@ -43,6 +43,9 @@ $dateValue = isset($published_at)
value="<?= htmlspecialchars($title ?? '') ?>"> value="<?= htmlspecialchars($title ?? '') ?>">
</div> </div>
<?php if ($action === 'edit'): ?>
<input type="hidden" id="slug" name="slug" value="<?= htmlspecialchars($postSlug ?? '') ?>">
<?php else: ?>
<div class="mb-3"> <div class="mb-3">
<label for="slug" class="form-label"> <label for="slug" class="form-label">
Slug <small class="text-muted">(URL : /post/<span id="slug-preview"><?= htmlspecialchars($postSlug ?? '') ?></span>)</small> Slug <small class="text-muted">(URL : /post/<span id="slug-preview"><?= htmlspecialchars($postSlug ?? '') ?></span>)</small>
@@ -52,6 +55,7 @@ $dateValue = isset($published_at)
pattern="[a-z0-9][a-z0-9\-]*" pattern="[a-z0-9][a-z0-9\-]*"
placeholder="généré automatiquement depuis le titre"> placeholder="généré automatiquement depuis le titre">
</div> </div>
<?php endif; ?>
<?php if ($action === 'create'): ?> <?php if ($action === 'create'): ?>
<div class="mb-3"> <div class="mb-3">
@@ -130,63 +134,6 @@ $dateValue = isset($published_at)
</div> </div>
</div> </div>
<div class="mb-3">
<label for="revision_comment" class="form-label">Commentaire de révision <small class="text-muted">(optionnel)</small></label>
<input type="text" class="form-control" id="revision_comment" name="revision_comment"
placeholder="ex. Correction typos, ajout section X…">
</div>
<div class="card mb-3 border-secondary">
<div class="card-header bg-transparent py-2">
<button class="btn btn-sm btn-link text-secondary text-decoration-none p-0 fw-semibold"
type="button" data-bs-toggle="collapse" data-bs-target="#seoPanel" aria-expanded="false">
▸ SEO — titre, description, image
</button>
</div>
<div class="collapse" id="seoPanel">
<div class="card-body">
<div class="mb-3">
<label for="seo_title" class="form-label">
Titre SEO
<small class="text-muted">(balise &lt;title&gt; et og:title)</small>
</label>
<input type="text" class="form-control" id="seo_title" name="seo_title"
maxlength="70"
value="<?= htmlspecialchars($seoTitle ?? '') ?>"
placeholder="Généré automatiquement depuis le titre">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 3060 caractères</small>
<small id="seo_title_counter" class="text-muted">0 / 60</small>
</div>
</div>
<div class="mb-3">
<label for="seo_description" class="form-label">
Description SEO
<small class="text-muted">(meta description et og:description)</small>
</label>
<textarea class="form-control" id="seo_description" name="seo_description"
rows="3" maxlength="200"
placeholder="Générée automatiquement depuis le début du contenu"><?= htmlspecialchars($seoDescription ?? '') ?></textarea>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 120155 caractères</small>
<small id="seo_desc_counter" class="text-muted">0 / 155</small>
</div>
</div>
<div class="mb-2">
<label for="og_image" class="form-label">
Image Open Graph
<small class="text-muted">(URL absolue, optionnel)</small>
</label>
<input type="url" class="form-control font-monospace" id="og_image" name="og_image"
value="<?= htmlspecialchars($ogImage ?? '') ?>"
placeholder="https://varlog.a5l.fr/…">
</div>
</div>
</div>
</div>
<div class="d-flex align-items-center gap-3 flex-wrap mb-4"> <div class="d-flex align-items-center gap-3 flex-wrap mb-4">
<button type="submit" class="btn btn-success">Enregistrer</button> <button type="submit" class="btn btn-success">Enregistrer</button>
<a href="/" class="btn btn-secondary">Annuler</a> <a href="/" class="btn btn-secondary">Annuler</a>
@@ -365,6 +312,7 @@ if ($hasSources):
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($article['revisions'])): ?> <?php if (!empty($article['revisions'])): ?>
<hr class="my-4"> <hr class="my-4">
<div> <div>
+3 -4
View File
@@ -5,7 +5,7 @@ $Parsedown = new Parsedown();
ob_start(); ob_start();
?> ?>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4"> <div class="post-grid">
<?php foreach ($posts as $i => $post): ?> <?php foreach ($posts as $i => $post): ?>
<?php <?php
$html = $Parsedown->text($post['content']); $html = $Parsedown->text($post['content']);
@@ -19,8 +19,7 @@ ob_start();
$isPrivate = $postCat !== '' && in_array($postCat, $privateCats ?? [], true); $isPrivate = $postCat !== '' && in_array($postCat, $privateCats ?? [], true);
$isLocked = $isAvantPremiere; $isLocked = $isAvantPremiere;
?> ?>
<div class="col"> <article class="card">
<article class="card h-100">
<?php if ($isDraft): ?> <?php if ($isDraft): ?>
<div class="draft-ribbon">Brouillon</div> <div class="draft-ribbon">Brouillon</div>
<?php elseif ($isAvantPremiere): ?> <?php elseif ($isAvantPremiere): ?>
@@ -68,7 +67,6 @@ ob_start();
<a href="<?= htmlspecialchars($postUrl) ?>" class="stretched-link"></a> <a href="<?= htmlspecialchars($postUrl) ?>" class="stretched-link"></a>
<?php endif; ?> <?php endif; ?>
</article> </article>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@@ -124,4 +122,5 @@ if (empty($cursor) && $filterCat === '') {
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} }
$mainClass = 'container-fluid';
include __DIR__ . '/layout.php'; include __DIR__ . '/layout.php';