From 1d2e3d9a24014001be275d10fd69a3ec205d5ecb Mon Sep 17 00:00:00 2001 From: Cedric Abonnel Date: Tue, 12 May 2026 15:51:06 +0200 Subject: [PATCH] feat: roles, permissions, grille full-width, SSO display name - Admin/roles : tableau des roles avec edition par role (/admin/role/) - 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 --- public/.htaccess | 3 +- public/assets/css/style.css | 7 + public/assets/js/add_files.js | 118 ++++++++++++++ public/assets/js/app.js | 19 +++ public/assets/js/post_confirm.js | 62 ++++++++ public/index.php | 264 +++++++++++++++++++++++++++---- public/oidc/callback.php | 56 ++++--- src/auth.php | 42 +++-- templates/add_files.php | 143 ++++++++++------- templates/admin.php | 230 ++++++++++----------------- templates/admin_role_edit.php | 71 +++++++++ templates/layout.php | 17 +- templates/post_confirm.php | 258 ++++++++++++++++++++++++++++++ templates/post_form.php | 62 +------- templates/post_list.php | 9 +- 15 files changed, 1029 insertions(+), 332 deletions(-) create mode 100644 public/assets/js/add_files.js create mode 100644 public/assets/js/post_confirm.js create mode 100644 templates/admin_role_edit.php create mode 100644 templates/post_confirm.php diff --git a/public/.htaccess b/public/.htaccess index 60ef108..e756204 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -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 ^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/) +# Admin (regen-thumbs et role/ avant la règle générique admin/) 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/?$ /index.php?action=admin [L,QSA] diff --git a/public/assets/css/style.css b/public/assets/css/style.css index 8488aaa..fdc568b 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -816,6 +816,13 @@ textarea.form-control { 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-meta { font-size: 0.8rem; diff --git a/public/assets/js/add_files.js b/public/assets/js/add_files.js new file mode 100644 index 0000000..165b7cc --- /dev/null +++ b/public/assets/js/add_files.js @@ -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 = '

Recherche…

'; + 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 = '

Aucun fichier trouvé.

'; + 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) + '' + f.name.split('.').pop().toUpperCase() + ''; + 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 = '

Erreur de recherche.

'; + } finally { + btn.disabled = false; + } + } + + btn.addEventListener('click', doSearch); + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); doSearch(); } + }); + doSearch(); +}); diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 42db54a..9f85bfa 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -50,6 +50,25 @@ document.addEventListener('DOMContentLoaded', function () { .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 ──────────────────────────────────────────── const KNOWN_CATS = { 'actualité': 10, 'travaux': 35, 'scolaire': 55, diff --git a/public/assets/js/post_confirm.js b/public/assets/js/post_confirm.js new file mode 100644 index 0000000..2c0834f --- /dev/null +++ b/public/assets/js/post_confirm.js @@ -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(); +}); diff --git a/public/index.php b/public/index.php index 9a025ea..bbcfc02 100644 --- a/public/index.php +++ b/public/index.php @@ -603,36 +603,88 @@ switch ($action) { $errors[] = 'Le titre est obligatoire.'; } if (empty($errors)) { - $articles->update( - $uuid, - $title, - $content, - $published, - $_POST['slug'] ?? '', - str_replace('T', ' ', $_POST['published_at'] ?? ''), - $_POST['revision_comment'] ?? '', - $_POST['seo_title'] ?? '', - $_POST['seo_description'] ?? '', - $_POST['og_image'] ?? '', - $_POST['category'] ?? '' - ); + if (!empty($_POST['_confirm'])) { + $coverFile = trim($_POST['cover_file'] ?? '') ?: ($article['cover'] ?? ''); + $ogImageFromCover = $coverFile !== '' + ? rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFile) + : ''; - // Métadonnées des fichiers existants (auteur, source) + $articles->update( + $uuid, + $title, + $content, + $published, + $_POST['slug'] ?? '', + str_replace('T', ' ', $_POST['published_at'] ?? ''), + $_POST['revision_comment'] ?? '', + $_POST['seo_title'] ?? '', + $_POST['seo_description'] ?? '', + $ogImageFromCover, + $_POST['category'] ?? '' + ); + + $fmetaNames = $_POST['fmeta_name'] ?? []; + $fmetaAuthors = $_POST['fmeta_author'] ?? []; + $fmetaSources = $_POST['fmeta_source'] ?? []; + foreach ($fmetaNames as $fi => $fname) { + $articles->addFileMeta($uuid, $fname, trim($fmetaAuthors[$fi] ?? ''), trim($fmetaSources[$fi] ?? '')); + } + + $coverFile = trim($_POST['cover_file'] ?? ''); + if ($coverFile !== '') { + $articles->setCover($uuid, $coverFile); + } + + $updated = $articles->getByUuid($uuid); + header('Location: /post/' . rawurlencode($updated['slug'] ?? $uuid)); + 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) { - $articles->addFileMeta($uuid, $fname, trim($fmetaAuthors[$fi] ?? ''), trim($fmetaSources[$fi] ?? '')); + $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)) : ''; - // Cover - $coverFile = trim($_POST['cover_file'] ?? ''); - if ($coverFile !== '') { - $articles->setCover($uuid, $coverFile); - } + 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); - $updated = $articles->getByUuid($uuid); - header('Location: /post/' . rawurlencode($updated['slug'] ?? $uuid)); + include BASE_PATH . '/templates/post_confirm.php'; exit; } } @@ -795,6 +847,36 @@ switch ($action) { echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]); 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': requireAuth(); $addFilesArticle = $articles->getByUuid($uuid); @@ -1305,7 +1387,6 @@ switch ($action) { ORDER BY r.name' ); $roles = $st->fetchAll(PDO::FETCH_ASSOC); - // Charge les capacités par rôle try { $capRows = $pdo->query('SELECT role_id, capability FROM role_capabilities')->fetchAll(PDO::FETCH_ASSOC); $capsMap = []; @@ -1344,10 +1425,10 @@ switch ($action) { // 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 = []; 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 = []; @@ -1355,7 +1436,7 @@ switch ($action) { $merged[$email] = [ 'email' => $email, 'is_active' => $usersFromDb[$email] ?? null, - 'roles' => $rolesMap[$email] ?? [], + 'roles' => $rolesMap[$email] ?? [], // [['name'=>..., 'label'=>...], ...] ]; } ksort($merged); @@ -1405,6 +1486,19 @@ switch ($action) { if ($targetEmail && $roleName) { $pdo = dbPdo(); 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( 'DELETE FROM user_roles WHERE user_email = :email @@ -1422,8 +1516,11 @@ switch ($action) { http_response_code(403); exit; } - $roleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_POST['name'] ?? ''))); $roleLabel = trim($_POST['label'] ?? ''); + $roleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_POST['name'] ?? ''))); + if ($roleName === '' && $roleLabel !== '') { + $roleName = slugify($roleLabel); + } if ($roleName && $roleLabel) { $pdo = dbPdo(); if ($pdo) { @@ -1495,6 +1592,69 @@ switch ($action) { header('Location: /admin/roles'); 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': requireAuth(); $profileError = ''; @@ -1525,6 +1685,56 @@ switch ($action) { include BASE_PATH . '/templates/profile.php'; 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': require_once BASE_PATH . '/src/SearchEngine.php'; $searchQuery = trim($_GET['q'] ?? ''); diff --git a/public/oidc/callback.php b/public/oidc/callback.php index 603082f..5bd8fe4 100644 --- a/public/oidc/callback.php +++ b/public/oidc/callback.php @@ -139,19 +139,48 @@ if (!$email) { } // Nom d'affichage depuis les claims SSO -$displayName = ''; +$ssoName = ''; 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'])) { - $displayName = trim($claims['name']); + $ssoName = trim($claims['name']); } 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 session_regenerate_id(true); $_SESSION['user_email'] = strtolower(trim($email)); -$_SESSION['user_display_name'] = $displayName; +$_SESSION['user_display_name'] = $sessionName; $_SESSION['oidc'] = [ 'issuer' => $OIDC_ISSUER, 'sub' => $claims['sub'] ?? null, @@ -160,23 +189,6 @@ $_SESSION['oidc'] = [ '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'] ?? '/'; unset($_SESSION['oidc_return_to'], $_SESSION['oidc_flow']); if (!is_string($target) || $target === '' || $target[0] !== '/') { diff --git a/src/auth.php b/src/auth.php index 2d2eccf..bf9c99a 100644 --- a/src/auth.php +++ b/src/auth.php @@ -119,22 +119,40 @@ function hasRole(string $role): bool // Capacités connues — clé => label affiché dans l'admin const KNOWN_CAPABILITIES = [ - 'view_sources_own' => 'Voir les sources de ses propres articles', - 'view_sources_all' => 'Voir les sources de tous les articles', - 'view_drafts_own' => 'Voir ses articles non publiés', - 'view_drafts_all' => 'Voir tous les articles non publiés', - 'edit_articles_own' => 'Modifier ses propres articles', - 'edit_articles_all' => 'Modifier tous les articles', - 'rate_articles' => 'Noter des articles', + 'propose_articles' => 'Proposer des articles', + 'validate_articles_all' => 'Valider des articles', + 'validate_articles_own' => 'Valider ses articles uniquement', + 'publish_articles_all' => 'Publier des articles', + 'publish_articles_own' => 'Publier ses articles uniquement', + 'edit_articles_all' => 'Modifier des articles', + 'edit_articles_own' => 'Modifier ses articles uniquement', + '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 -// 'single' => pas de variante own/all const CAPABILITY_GROUPS = [ - ['label' => 'Sources & métadonnées', 'own' => 'view_sources_own', 'all' => 'view_sources_all'], - ['label' => 'Articles non publiés', 'own' => 'view_drafts_own', 'all' => 'view_drafts_all'], - ['label' => 'Modification', 'own' => 'edit_articles_own', 'all' => 'edit_articles_all'], - ['label' => 'Noter des articles', 'single' => 'rate_articles'], + 'Articles' => [ + 'propose_articles', + 'validate_articles_all', + '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 diff --git a/templates/add_files.php b/templates/add_files.php index 057b3a6..2461960 100644 --- a/templates/add_files.php +++ b/templates/add_files.php @@ -1,87 +1,124 @@ 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); ?>
- ← Retour + ← Retour

Ajouter des fichiers

- Article : + Article :

- -
-
+ +
+
+
Uploader
-
- - +
- Images → nommées sha256-taille.ext
+ Images → sha256-taille.ext
Vidéos, PDF, autres → nom sanitisé
-
- - Annuler + + Annuler
-
+ + + +
+
+
Fichiers existants
+
+ +
+ + + + + '🎬', + str_starts_with($f['mime'], 'audio/') => '🎵', + $f['mime'] === 'application/pdf' => '📑', + default => '📄', + } ?> + + +
+ + Ko +
+ + cover + +
+ +
+
+
+ +
+ + +
+
+
+
Fichiers d'autres articles
+
+ + +
+
+
+
- - -
-
Fichiers existants
-
- - -
- - - - - - - '🎬', - str_starts_with($f['mime'], 'audio/') => '🎵', - $f['mime'] === 'application/pdf' => '📑', - default => '📄', - } ?> - - -
- - Ko -
- - cover - -
- -
-
- -
+ + + +
+ Impossible de retirer le rôle Administrateur : il doit rester au moins un administrateur. +
+ +
Attribuer un rôle
@@ -198,13 +204,13 @@ function adminStatusBadge(array $a, int $now): string - - + +
+ data-confirm="Retirer le rôle «» à ?"> - +
@@ -215,8 +221,8 @@ function adminStatusBadge(array $a, int $now): string !in_array($r['name'], $currentRoles, true)); + $currentRoleNames = array_column($u['roles'], 'name'); + $missing = array_filter($adminData['roles'], fn ($r) => !in_array($r['name'], $currentRoleNames, true)); ?>
@@ -241,151 +247,81 @@ function adminStatusBadge(array $a, int $now): string -
+
- -
-
Rôles existants
- -

Aucun rôle défini.

- - - - - - - - - - - - - - - - - - - - -
Nom techniqueLabel affichéUtilisateurs
- - - - - - - - -
- - -
-
- -
- - -
-
-
Nouveau rôle
-
-
-
- - -
Utilisé dans le code — ne change pas.
-
-
- - -
- -
-
-
-
- - - -
-
Permissions par rôle
-

Le rôle admin a toutes les permissions implicitement.

-
- -
-
-
- - -
-
-
- - - - -
- > - -
- -
-
-
- 'Propres articles', 'all' => 'Tous'] as $scope => $scopeLabel): - $cap = $group[$scope]; ?> -
- > - -
- -
-
- - - -
-
-
-
- -
-
+ +
+ +

Aucun rôle défini.

+ + + + + + + + + + + + + + + + + + +
RôleUtilisateurs
+ + +
+ KNOWN_CAPABILITIES[$c] ?? $c, + $r['capabilities'] + ); + echo htmlspecialchars(implode(', ', $capLabels) ?: '–'); + ?> +
+ +
Toutes les permissions
+ +
+ + + Éditer + +
+ + +
+ +
-
+ +
+
+
Nouveau rôle
+
+
+
+ + +
+ + +
+
+
+
+ +
+ + +
+ ← Retour +

Rôle :

+ +
+ +
+ +
+ + +
+ + +
+ Le rôle admin a toutes les permissions implicitement — les cases à cocher sont ignorées. +
+ + +
+
+ $groupCaps): ?> +
+
+ + +
+ > + +
+ +
+ +
+
+ + +
+ + +
+
+
+
+ + +
+ + Annuler +
+
+
+ +
+
+ + - - - - - diff --git a/templates/post_confirm.php b/templates/post_confirm.php new file mode 100644 index 0000000..fdf3043 --- /dev/null +++ b/templates/post_confirm.php @@ -0,0 +1,258 @@ + + +
+ ← Retour à l'édition +

Confirmer les modifications

+
+ +
+ + + + + + + + + + + + + + $fname): ?> + + + + + +
+
+ + +
+ +
+ +
Aucune modification détectée.
+ + + +
+

Diff du contenu

+ +
Contenu identique.
+ +
+ − Supprimé + + Ajouté +
+
+ + + + +
+ + + +
+ +
− 
+ +
+ +
  
+ + +
+ +
+ + + +
+ + + +
+ Slug recalculé depuis le nouveau titre. Slug initial : + + +
+ +
+ + +
+ + +
+ +
+ + ← Retour +
+ +
+ + +
+
+
+ SEO — titre, description, image +
+
+
+ + +
+ Idéal : 30–60 car. + 0 / 60 +
+
+ +
+ + +
+ Idéal : 120–155 car. + 0 / 155 +
+
+ +
+
+ +
+
+ Aperçu dans les moteurs de recherche +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
AuteurCédrix
Publication
Modification (après enreg.)
Languefr-FR
Catégorie—' ?>
og:image + ' . htmlspecialchars($coverFilename) . '' : '— (pas de couverture)' ?> +
+
+
+
+ +
+
+ + + + + +">
+ + +
+
@@ -130,63 +134,6 @@ $dateValue = isset($published_at)
-
- - -
- -
-
- -
-
-
-
- - -
- Idéal : 30–60 caractères - 0 / 60 -
-
- -
- - -
- Idéal : 120–155 caractères - 0 / 155 -
-
- -
- - -
-
-
-
-
Annuler @@ -365,6 +312,7 @@ if ($hasSources): +
diff --git a/templates/post_list.php b/templates/post_list.php index 75022f2..1056bcb 100644 --- a/templates/post_list.php +++ b/templates/post_list.php @@ -5,7 +5,7 @@ $Parsedown = new Parsedown(); ob_start(); ?> -
+
$post): ?> text($post['content']); @@ -19,8 +19,7 @@ ob_start(); $isPrivate = $postCat !== '' && in_array($postCat, $privateCats ?? [], true); $isLocked = $isAvantPremiere; ?> -
-
+
Brouillon
@@ -67,8 +66,7 @@ ob_start(); -
-
+
@@ -124,4 +122,5 @@ if (empty($cursor) && $filterCat === '') { ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } +$mainClass = 'container-fluid'; include __DIR__ . '/layout.php';