/* Espace d'administration : connexion, modération, arborescence, admins, import. */ 'use strict'; const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"' }[c] )); const $ = (id) => document.getElementById(id); async function apiJson(url, opts) { const res = await fetch(url, opts); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status)); return data; } const postJson = (url, body) => apiJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; let me = null; let folders = []; let allUsers = []; // liste des utilisateurs (pour l'ajout de membres aux groupes) // --- Démarrage / session --- async function boot() { try { me = (await apiJson('api/auth.php?action=me')).user; } catch { me = null; } me ? showAdmin() : showLogin(); } function showLogin() { $('login').hidden = false; $('admin').hidden = true; $('logout').hidden = true; $('who').textContent = ''; } const ROLE_LABEL = { super: 'admin plateforme', perimeter: 'admin périmètre', member: 'commentateur' }; async function showAdmin() { // Changement de mot de passe forcé : on redirige avant tout. if (me.must_change) { location.href = 'account.html?forced=1'; return; } $('login').hidden = true; $('admin').hidden = false; $('logout').hidden = false; $('account-link').hidden = false; $('who').textContent = `${me.username} — ${ROLE_LABEL[me.role] || me.role}`; $('super-only').hidden = me.role !== 'super'; // Commentateur : pas de file de modération ; il commente depuis les fiches. if (me.role === 'member') { $('queue-count').textContent = ''; $('queue').innerHTML = '

Compte commentateur : rendez-vous sur une fiche d’établissement (onglet 💬 Commentaires) pour participer.

'; return; } await loadQueue(); if (me.role === 'super') { await loadFolders(); await loadUsers(); await loadGroups(); } } $('login-form').addEventListener('submit', async (ev) => { ev.preventDefault(); try { me = (await postJson('api/auth.php?action=login', { username: $('l-user').value, password: $('l-pass').value })).user; $('login-msg').textContent = ''; await showAdmin(); } catch (err) { $('login-msg').textContent = err.message; } }); $('logout').addEventListener('click', async () => { await apiJson('api/auth.php?action=logout', { method: 'POST' }); me = null; showLogin(); }); // --- File de validation --- async function loadQueue() { let q; try { q = await apiJson('api/items.php?action=queue'); } catch (err) { $('queue').innerHTML = `

${esc(err.message)}

`; return; } $('queue-count').textContent = q.length || ''; if (!q.length) { $('queue').innerHTML = '

Aucun élément en attente. 🎉

'; return; } $('queue').innerHTML = q.map((it) => { const icon = it.kind === 'photo' ? '🖼' : it.kind === 'link' ? '🔗' : '📄'; let preview; if (it.kind === 'photo') preview = ``; else if (it.kind === 'link') preview = `${esc(it.url)}`; else preview = `Télécharger`; const who = [it.submitter_name, it.submitter_email].filter(Boolean).join(' · ') || 'Anonyme'; const descHtml = it.description ? `
${esc(it.description)}
` : ''; return `
${icon} ${esc(it.title)}
${descHtml}
${esc(it.etab)}
Proposé par : ${esc(who)} · ${esc(it.submitted_at || '')}
${preview}
`; }).join(''); } $('queue').addEventListener('click', async (ev) => { const card = ev.target.closest('.q-item'); if (!card) return; const id = Number(card.dataset.id); try { if (ev.target.closest('.q-approve')) { await postJson('api/items.php?action=validate', { id, decision: 'approve' }); } else if (ev.target.closest('.q-reject')) { const reason = prompt('Motif du rejet (facultatif) :') ?? ''; await postJson('api/items.php?action=validate', { id, decision: 'reject', reason }); } else return; await loadQueue(); } catch (err) { alert('Erreur : ' + err.message); } }); // --- Arborescence (super-admin) --- async function loadFolders() { folders = await apiJson('api/folders.php'); renderFolderParentSelect(); renderFoldersAdmin(); } const childrenOf = (pid) => folders .filter((f) => f.parent_id === pid) .sort((a, b) => a.position - b.position || a.name.localeCompare(b.name, 'fr')); function renderFolderParentSelect() { const opts = ['']; const walk = (pid, d) => childrenOf(pid).forEach((f) => { opts.push(``); walk(f.id, d + 1); }); walk(null, 0); $('nf-parent').innerHTML = opts.join(''); } function folderNodeHtml(f) { const kids = childrenOf(f.id); let html = `
  • 📁 ${esc(f.name)} `; if (kids.length) html += ''; return html + '
  • '; } function renderFoldersAdmin() { const roots = childrenOf(null); $('folders-admin').innerHTML = roots.length ? '' : '

    Aucun dossier.

    '; } $('folder-form').addEventListener('submit', async (ev) => { ev.preventDefault(); try { await postJson('api/folders.php', { name: $('nf-name').value.trim(), parent_id: $('nf-parent').value || null }); $('nf-name').value = ''; await loadFolders(); } catch (err) { alert('Erreur : ' + err.message); } }); $('folders-admin').addEventListener('click', async (ev) => { const row = ev.target.closest('.fa-row'); if (!row) return; const id = Number(row.dataset.id); const name = row.querySelector('.fa-name').textContent.replace(/^📁\s*/, ''); try { if (ev.target.closest('.fa-rename')) { const nv = prompt('Nouveau nom :', name); if (nv && nv.trim()) { await apiJson('api/folders.php', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, name: nv.trim() }) }); await loadFolders(); } } else if (ev.target.closest('.fa-del')) { if (confirm(`Supprimer « ${name} » et ses sous-dossiers ?\nLes éléments associés seront déplacés vers « Sans dossier ».`)) { await apiJson(`api/folders.php?id=${id}`, { method: 'DELETE' }); await loadFolders(); } } } catch (err) { alert('Erreur : ' + err.message); } }); // --- Administrateurs de périmètre (super-admin) --- async function loadUsers() { const users = await apiJson('api/users.php'); $('users-admin').innerHTML = users.map((u) => { const roleBadge = u.role === 'super' ? 'plateforme' : u.role === 'member' ? 'commentateur' : 'périmètre'; let scopes; if (u.role === 'super') scopes = 'Tous les établissements'; else if (u.role === 'member') scopes = 'Commente via ses groupes (aucune validation)'; else scopes = u.scopes.map((s) => `${esc(s.com)} (${esc(s.depc)}) ` ).join(' ') || 'Aucune commune'; // Périmètre personnel réservé aux admins de périmètre. const scopeEditor = u.role === 'perimeter' ? `
    ` : ''; const mustBadge = u.must_change ? '⚠ doit changer' : ''; const emailHtml = u.email ? esc(u.email) : 'pas d’e-mail'; return `
    ${esc(u.username)} ${roleBadge} ${mustBadge}
    📧 ${emailHtml}
    ${scopes}
    ${scopeEditor}
    `; }).join(''); } if (window.PWPolicy) PWPolicy.attach($('nu-pass'), $('nu-pw-meter')); $('user-form').addEventListener('submit', async (ev) => { ev.preventDefault(); const pw = $('nu-pass').value; if (window.PWPolicy) { const r = PWPolicy.check(pw); if (!r.ok) { alert('Mot de passe trop faible : ' + r.unmet.join(' ; ') + '.'); return; } } try { await postJson('api/users.php', { username: $('nu-user').value.trim(), email: $('nu-mail').value.trim(), password: pw, role: $('nu-role').value, }); $('nu-user').value = ''; $('nu-mail').value = ''; $('nu-pass').value = ''; $('nu-pw-meter').innerHTML = ''; await loadUsers(); } catch (err) { alert('Erreur : ' + err.message); } }); const searchCommunes = debounce(async (input) => { const q = input.value.trim(); const box = input.parentElement.querySelector('.scope-results'); if (q.length < 2) { box.innerHTML = ''; return; } try { const rows = await apiJson('api/communes.php?q=' + encodeURIComponent(q)); box.innerHTML = rows.map((r) => ``).join('') || '
    Aucune commune
    '; } catch { box.innerHTML = ''; } }, 250); $('users-admin').addEventListener('input', (ev) => { if (ev.target.classList.contains('scope-search')) searchCommunes(ev.target); }); $('users-admin').addEventListener('click', async (ev) => { const card = ev.target.closest('.user-card'); if (!card) return; const uid = Number(card.dataset.uid); try { if (ev.target.closest('.scope-result')) { const b = ev.target.closest('.scope-result'); await postJson('api/users.php?action=scope', { user_id: uid, com: b.dataset.com, depc: b.dataset.depc }); await loadUsers(); } else if (ev.target.closest('.chip-x')) { await apiJson(`api/users.php?action=scope&id=${ev.target.dataset.scope}`, { method: 'DELETE' }); await loadUsers(); } else if (ev.target.closest('.u-mail')) { const cur = card.querySelector('.user-email').textContent.replace(/^📧\s*/, '').trim(); const email = prompt('Adresse e-mail (vide pour retirer) :', cur === 'pas d’e-mail' ? '' : cur); if (email !== null) { await postJson('api/users.php?action=email', { id: uid, email: email.trim() }); await loadUsers(); } } else if (ev.target.closest('.u-pass')) { const pwd = prompt('Nouveau mot de passe (≥ 8 caractères, ≥ 3 types) :'); if (pwd) { if (window.PWPolicy && !PWPolicy.check(pwd).ok) { alert('Mot de passe trop faible : ' + PWPolicy.check(pwd).unmet.join(' ; ') + '.'); return; } await postJson('api/users.php?action=password', { id: uid, password: pwd }); alert('Mot de passe mis à jour. L’utilisateur devra le changer à sa prochaine connexion.'); await loadUsers(); } } else if (ev.target.closest('.u-del')) { if (confirm('Supprimer cet administrateur ?')) { await apiJson(`api/users.php?id=${uid}`, { method: 'DELETE' }); await loadUsers(); } } } catch (err) { alert('Erreur : ' + err.message); } }); // --- Groupes d'utilisateurs (super-admin) --- async function loadGroups() { const [groups, users] = await Promise.all([ apiJson('api/groups.php'), apiJson('api/users.php'), ]); allUsers = users; $('groups-admin').innerHTML = groups.map((g) => { const memberIds = new Set(g.members.map((m) => m.id)); const memberChips = g.members.map((m) => `${esc(m.username)} ` ).join(' ') || 'Aucun membre'; const addable = allUsers.filter((u) => !memberIds.has(u.id)); const memberSelect = addable.length ? `` : ''; const valBadge = g.can_validate ? 'valideur' : ''; let scopesHtml = ''; if (g.can_validate) { const chips = g.scopes.map((s) => `${esc(s.com)} (${esc(s.depc)}) ` ).join(' ') || 'Aucune commune'; scopesHtml = `
    ${chips}
    `; } return `
    ${esc(g.name)} ${valBadge}
    ${memberChips} ${memberSelect}
    ${scopesHtml}
    `; }).join('') || '

    Aucun groupe.

    '; } $('group-form').addEventListener('submit', async (ev) => { ev.preventDefault(); try { await postJson('api/groups.php', { name: $('ng-name').value.trim(), can_validate: $('ng-validate').checked }); $('ng-name').value = ''; $('ng-validate').checked = false; await loadGroups(); } catch (err) { alert('Erreur : ' + err.message); } }); $('groups-admin').addEventListener('input', (ev) => { if (ev.target.classList.contains('scope-search')) searchCommunes(ev.target); }); $('groups-admin').addEventListener('change', async (ev) => { const card = ev.target.closest('.group-card'); if (!card) return; const gid = Number(card.dataset.gid); try { if (ev.target.classList.contains('g-validate')) { await postJson('api/groups.php?action=toggle', { id: gid, can_validate: ev.target.checked }); await loadGroups(); } else if (ev.target.classList.contains('g-member-add') && ev.target.value) { await postJson('api/groups.php?action=member', { group_id: gid, user_id: Number(ev.target.value) }); await loadGroups(); } } catch (err) { alert('Erreur : ' + err.message); } }); $('groups-admin').addEventListener('click', async (ev) => { const card = ev.target.closest('.group-card'); if (!card) return; const gid = Number(card.dataset.gid); try { if (ev.target.closest('.scope-result')) { const b = ev.target.closest('.scope-result'); await postJson('api/groups.php?action=scope', { group_id: gid, com: b.dataset.com, depc: b.dataset.depc }); await loadGroups(); } else if (ev.target.closest('.chip-x') && ev.target.dataset.member) { await apiJson(`api/groups.php?action=member&group_id=${gid}&user_id=${ev.target.dataset.member}`, { method: 'DELETE' }); await loadGroups(); } else if (ev.target.closest('.chip-x') && ev.target.dataset.scope) { await apiJson(`api/groups.php?action=scope&id=${ev.target.dataset.scope}`, { method: 'DELETE' }); await loadGroups(); } else if (ev.target.closest('.g-del')) { if (confirm('Supprimer ce groupe ? Les membres et le périmètre associés seront retirés.')) { await apiJson(`api/groups.php?id=${gid}`, { method: 'DELETE' }); await loadGroups(); } } } catch (err) { alert('Erreur : ' + err.message); } }); // --- Import opendata (super-admin) --- $('import-btn').addEventListener('click', async () => { if (!confirm('Télécharger et importer la dernière version de l\'opendata ?\nCela peut prendre quelques minutes.')) return; const btn = $('import-btn'); btn.disabled = true; $('import-msg').className = 'propose-msg'; $('import-msg').textContent = '⏳ Téléchargement et import en cours…'; try { const r = await apiJson('api/import.php', { method: 'POST' }); $('import-msg').className = 'propose-msg ok'; $('import-msg').textContent = `✅ Import terminé : ${r.imported} établissements traités, ${r.skipped} ignorés. Total en base : ${r.total}.`; } catch (err) { $('import-msg').className = 'propose-msg err'; $('import-msg').textContent = 'Erreur : ' + err.message; } finally { btn.disabled = false; } }); // --- Import DPE ADEME + rattachement (super-admin) --- $('dpe-form').addEventListener('submit', async (ev) => { ev.preventDefault(); const dep = $('dpe-dep').value.trim(); const radius = Number($('dpe-radius').value) || 80; const scope = dep ? `du département ${dep}` : 'COMPLET (~532 000 DPE)'; if (!confirm(`Importer le dataset DPE ${scope} et rattacher les bâtiments aux écoles (rayon ${radius} m) ?\nCela peut prendre plusieurs minutes.`)) return; const btn = $('dpe-btn'); btn.disabled = true; $('dpe-msg').className = 'propose-msg'; $('dpe-msg').textContent = '⏳ Import et rattachement en cours… (ne fermez pas cette page)'; try { const r = await postJson('api/import_dpe.php', { dep, radius }); $('dpe-msg').className = 'propose-msg ok'; $('dpe-msg').textContent = `✅ Terminé : ${r.imported} DPE importés (${r.geocoded} géolocalisés) · ${r.links} rattachements pour ${r.etabs} établissements (≤ ${r.radius} m).`; } catch (err) { $('dpe-msg').className = 'propose-msg err'; $('dpe-msg').textContent = 'Erreur : ' + err.message; } finally { btn.disabled = false; } }); boot();