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 ^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/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]
+7
View File
@@ -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;
+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, '');
}
// ─── 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,
+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();
});
+237 -27
View File
@@ -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'] ?? '');
+34 -22
View File
@@ -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] !== '/') {