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:
+237
-27
@@ -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'] ?? '');
|
||||
|
||||
Reference in New Issue
Block a user