/** * Espace DSI (`PTA.modules.dsi`), écran dsi.php — V7.11. * * Deux blocs indépendants dans une seule page : * - les COMPTES D'ACCÈS : création, régénération de jeton, activation, * suppression, et génération en lot pour les identités qui n'ont pas encore * de compte ; * - le JOURNAL D'ACTIVITÉ : recherche filtrée et paginée, statistiques de tête, * purge des entrées anciennes. * * RÈGLE CENTRALE DE L'ÉCRAN : un jeton n'existe en clair que dans la réponse qui * vient de le produire. Il est donc affiché dans un panneau dédié, qui reste * ouvert tant que la DSI ne l'a pas refermé, et n'est jamais rechargé depuis le * serveur — la base n'en garde qu'une empreinte. Toute la mise en garde de * l'interface découle de là. */ (() => { 'use strict'; const PTA = window.PTA; // core.js absent : la page reste lisible, simplement non interactive. if (!PTA) return; const { readJsonResponse, apiErrorMessage, escapeHtml } = PTA.utils; const el = (id) => document.getElementById(id); /** Page courante du journal, seul état conservé par le module. */ let pageJournal = 1; const LIBELLES_ROLE = { DSI: 'DSI', SERVICE_ENFANCE: 'Service Enfance', RESPONSABLE_STRUCTURE: 'Responsable de structure', AGENT: 'Agent', }; const LIBELLES_CATEGORIE = { CONNEXION: 'Connexion', ECRITURE: 'Écriture', ACCES_REFUSE: 'Refus d’accès', ADMINISTRATION: 'Administration', SYSTEME: 'Système', }; /** Message dans un des bandeaux de la page. */ function message(id, texte, type = 'info') { const boite = el(id); if (!boite) return; boite.className = `message message-${type}`; boite.textContent = texte; } /** * Appel d'API mutualisé : lit le JSON et transforme toute réponse non-OK en * exception. Toutes les actions de la page passent par ici. */ async function api(url, options = {}) { const reponse = await fetch(url, options); const donnees = await readJsonResponse(reponse); if (!reponse.ok) throw new Error(apiErrorMessage(donnees, 'Opération impossible.')); return donnees; } /** Raccourci des appels mutants : POST JSON avec le jeton CSRF du corps. */ function poster(url, charge) { return api(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, ...charge }), }); } /** « 2026-08-20 09:41:07.482 » → « 20/08/2026 09:41 ». */ function formatDateHeure(valeur) { if (!valeur) return '—'; const [date, heure = ''] = String(valeur).split(' '); const [annee, mois, jour] = date.split('-'); return `${jour}/${mois}/${annee} ${heure.slice(0, 5)}`; } // ------------------------------------------------------------------ jetons /** * Affiche les jetons produits par la dernière action. * * @param {Array<{libelle: string, role: string, jeton: string}>} jetons */ function afficherJetons(jetons) { const panneau = el('dsi-token-panel'); const liste = el('dsi-token-list'); if (!panneau || !liste || !jetons.length) return; liste.innerHTML = jetons.map((entree) => `
${escapeHtml(entree.libelle)} ${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role)}
${escapeHtml(entree.jeton)}
`).join(''); panneau.hidden = false; message('dsi-token-message', `${jetons.length} jeton(s) à transmettre. Ils ne seront plus affichés après fermeture. Les comptes Service Enfance et DSI choisiront en plus un mot de passe à leur première connexion.`, 'warning'); panneau.scrollIntoView({ behavior: 'smooth', block: 'start' }); } /** * Copie les jetons affichés dans le presse-papiers. * * L'API Clipboard n'existe QUE dans un contexte sécurisé : sur une * installation en http:// sur le réseau de la collectivité, elle est * absente. D'où le repli par zone de texte masquée, puis, en dernier * ressort, l'invitation à sélectionner à la main. */ async function copierJetons() { const lignes = Array.from(document.querySelectorAll('#dsi-token-list .dsi-token-row')).map((ligne) => { const identite = ligne.querySelector('strong')?.textContent || ''; const jeton = ligne.querySelector('.dsi-token-value')?.textContent || ''; return `${identite}\t${jeton}`; }); if (!lignes.length) return; const texte = lignes.join('\n'); try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(texte); } else { const zone = document.createElement('textarea'); zone.value = texte; zone.setAttribute('readonly', 'readonly'); zone.className = 'dsi-clipboard-relay'; document.body.appendChild(zone); zone.select(); document.execCommand('copy'); zone.remove(); } message('dsi-token-message', `${lignes.length} jeton(s) copié(s) dans le presse-papiers.`, 'success'); } catch (erreur) { message('dsi-token-message', 'Copie impossible sur ce navigateur : sélectionnez les jetons à la main.', 'error'); } } /** Referme le panneau des jetons ; leur contenu est perdu pour de bon. */ function fermerJetons() { const panneau = el('dsi-token-panel'); if (!panneau) return; panneau.hidden = true; el('dsi-token-list').innerHTML = ''; message('dsi-token-message', ''); } // ----------------------------------------------------------------- comptes /** Une ligne du tableau des comptes. */ function ligneCompte(compte) { const identite = [ `${escapeHtml(compte.identite_libelle)}`, compte.matricule ? `
Matricule ${escapeHtml(compte.matricule)}` : '', compte.commentaire ? `
${escapeHtml(compte.commentaire)}` : '', ].join(''); const etats = []; if (!compte.actif) etats.push('Désactivé'); else if (!compte.identite_active) etats.push('Identité inactive'); else etats.push('Actif'); if (compte.jeton_installation) etats.push('Jeton d’installation'); // Second facteur (V7.12) : seuls les comptes Service Enfance et DSI en // portent un. « À définir » n'est pas une anomalie — c'est l'état normal // d'un compte neuf ou fraîchement réinitialisé. if (compte.mot_de_passe_requis) { etats.push(compte.mot_de_passe_defini ? 'Mot de passe défini' : 'Mot de passe à définir'); } const connexions = compte.nombre_connexions > 0 ? `
${compte.nombre_connexions} connexion(s)` : ''; return ` ${identite} ${escapeHtml(LIBELLES_ROLE[compte.role] || compte.role)} ${escapeHtml(compte.jeton_prefixe)}… ${formatDateHeure(compte.date_derniere_connexion)}${connexions} ${etats.join(' ')} ${compte.mot_de_passe_requis && compte.mot_de_passe_defini ? '' : ''} `; } /** * Remplit les listes déroulantes de création à partir des identités SANS * compte : proposer un agent qui en a déjà un ne mènerait qu'à un refus du * serveur (un seul compte par identité). */ function remplirIdentites(manquants) { const agents = manquants.filter((identite) => identite.role === 'AGENT'); const lieux = manquants.filter((identite) => identite.role === 'RESPONSABLE_STRUCTURE'); const selectAgent = el('dsi-account-agent'); const selectLieu = el('dsi-account-structure'); if (selectAgent) { selectAgent.innerHTML = '' + agents.map((agent) => ``).join(''); } if (selectLieu) { selectLieu.innerHTML = '' + lieux.map((lieu) => ``).join(''); } const compteur = el('dsi-missing-count'); if (compteur) { compteur.textContent = String(manquants.length); compteur.hidden = manquants.length === 0; } const bouton = el('dsi-generate-missing'); if (bouton) bouton.disabled = manquants.length === 0; } /** Recharge la liste des comptes selon les filtres courants. */ async function chargerComptes() { const parametres = new URLSearchParams(); const role = el('dsi-filter-role')?.value || ''; const etat = el('dsi-filter-state')?.value || ''; const recherche = el('dsi-filter-search')?.value.trim() || ''; if (role) parametres.set('role', role); if (etat !== '') parametres.set('actif', etat); if (recherche) parametres.set('q', recherche); message('dsi-accounts-message', 'Chargement des comptes…'); try { const donnees = await api(`api/dsi_comptes.php?${parametres.toString()}`); const comptes = donnees.comptes || []; el('dsi-accounts-body').innerHTML = comptes.length ? comptes.map(ligneCompte).join('') : 'Aucun compte ne correspond à ces filtres.'; remplirIdentites(donnees.manquants || []); const alerte = el('dsi-installation-warning'); if (alerte) alerte.hidden = !donnees.jeton_installation_actif; message('dsi-accounts-message', `${comptes.length} compte(s) affiché(s).`, 'success'); } catch (erreur) { message('dsi-accounts-message', erreur.message, 'error'); } } /** * Ce qu'il faut renseigner change entièrement selon le type de compte : * l'écran l'explique plutôt que de laisser deviner pourquoi un champ * apparaît ou disparaît. */ const AIDES_ROLE = { SERVICE_ENFANCE: 'Compte d’administration métier : accès à tous les lieux, à tous les agents et à la validation des plannings. Il n’est rattaché à aucun agent en base — indiquez simplement le nom de son titulaire. Son titulaire choisira un mot de passe à sa première connexion, en plus du jeton.', DSI: 'Compte d’administration technique : comptes d’accès et journal uniquement, aucun accès aux plannings. Prévoyez-en au moins deux, pour ne pas dépendre d’un seul jeton. Son titulaire choisira un mot de passe à sa première connexion, en plus du jeton.', AGENT: 'Compte en lecture seule sur son propre planning. Seuls les agents actifs qui n’ont pas encore de compte sont proposés.', RESPONSABLE_STRUCTURE: 'Compte d’édition des brouillons d’un seul lieu. Seuls les lieux actifs qui n’ont pas encore de compte sont proposés.', }; /** Affiche les seuls champs d'identité utiles au type de compte choisi. */ function ajusterFormulaireCompte() { const role = el('dsi-account-role')?.value || ''; const champAgent = el('dsi-account-agent-field'); const champLieu = el('dsi-account-structure-field'); const champLibelle = el('dsi-account-label-field'); if (!champAgent || !champLieu || !champLibelle) return; champAgent.hidden = role !== 'AGENT'; champLieu.hidden = role !== 'RESPONSABLE_STRUCTURE'; // Service Enfance et DSI ne pointent vers aucune identité en base : leur // seul identifiant lisible est le libellé saisi ici. champLibelle.hidden = role === 'AGENT' || role === 'RESPONSABLE_STRUCTURE'; el('dsi-account-agent').required = role === 'AGENT'; el('dsi-account-structure').required = role === 'RESPONSABLE_STRUCTURE'; el('dsi-account-label').required = !champLibelle.hidden; const aide = el('dsi-account-hint'); if (aide) aide.textContent = AIDES_ROLE[role] || ''; } /** Crée un compte, puis affiche son jeton — unique occasion de le lire. */ async function creerCompte(evenement) { evenement.preventDefault(); const role = el('dsi-account-role').value; try { const donnees = await poster('api/dsi_compte_creer.php', { role, agent_id: role === 'AGENT' ? Number(el('dsi-account-agent').value) : null, structure_id: role === 'RESPONSABLE_STRUCTURE' ? Number(el('dsi-account-structure').value) : null, libelle: el('dsi-account-label').value, commentaire: el('dsi-account-comment').value, }); message('dsi-account-message', donnees.message, 'success'); el('dsi-account-label').value = ''; el('dsi-account-comment').value = ''; afficherJetons([{ libelle: donnees.compte?.identite_libelle || '', role: donnees.compte?.role || role, jeton: donnees.jeton, }]); await chargerComptes(); } catch (erreur) { message('dsi-account-message', erreur.message, 'error'); } } /** Régénération, activation, suppression : délégation sur le tableau. */ async function agirSurCompte(evenement) { const ligne = evenement.target.closest('tr[data-compte-id]'); if (!ligne) return; const compteId = Number(ligne.dataset.compteId); const titulaire = ligne.querySelector('strong')?.textContent || 'ce compte'; try { if (evenement.target.closest('.dsi-regenerate')) { if (!window.confirm(`Régénérer le jeton de ${titulaire} ? L’ancien cessera immédiatement de fonctionner.`)) return; const donnees = await poster('api/dsi_compte_jeton.php', { compte_id: compteId }); afficherJetons([{ libelle: donnees.compte?.identite_libelle || titulaire, role: donnees.compte?.role || '', jeton: donnees.jeton, }]); message('dsi-accounts-message', donnees.message, 'success'); await chargerComptes(); return; } if (evenement.target.closest('.dsi-reset-password')) { if (!window.confirm(`Effacer le mot de passe de ${titulaire} ? Son jeton reste valable, et il en choisira un nouveau à sa prochaine connexion.`)) return; const donnees = await poster('api/dsi_compte_mot_de_passe.php', { compte_id: compteId }); message('dsi-accounts-message', donnees.message, 'success'); await chargerComptes(); return; } const bascule = evenement.target.closest('.dsi-toggle'); if (bascule) { const action = bascule.textContent.trim() === 'Désactiver' ? 'DESACTIVER' : 'ACTIVER'; const donnees = await poster('api/dsi_compte_statut.php', { compte_id: compteId, action }); message('dsi-accounts-message', donnees.message, 'success'); await chargerComptes(); return; } if (evenement.target.closest('.dsi-delete')) { if (!window.confirm(`Supprimer définitivement le compte de ${titulaire} ? Ses traces dans le journal seront conservées.`)) return; const donnees = await poster('api/dsi_compte_statut.php', { compte_id: compteId, action: 'SUPPRIMER' }); message('dsi-accounts-message', donnees.message, 'success'); await chargerComptes(); return; } if (evenement.target.closest('.dsi-see-journal')) { // Filtre le journal sur ce compte, sans quitter la page. el('dsi-journal-search').value = ''; pageJournal = 1; await chargerJournal({ id_compte: compteId }); el('dsi-journal-body')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } catch (erreur) { message('dsi-accounts-message', erreur.message, 'error'); } } /** Équipe d'un coup toutes les identités actives sans compte. */ async function genererManquants() { const attendus = Number(el('dsi-missing-count')?.textContent || 0); if (!attendus) return; if (!window.confirm(`Générer ${attendus} jeton(s) ? Ils ne seront affichés qu’une seule fois, tout de suite après.`)) return; try { const donnees = await poster('api/dsi_comptes_manquants.php', {}); message('dsi-accounts-message', donnees.message, 'success'); afficherJetons(donnees.jetons || []); await chargerComptes(); } catch (erreur) { message('dsi-accounts-message', erreur.message, 'error'); } } // ----------------------------------------------------------------- journal /** Une ligne du journal. */ function ligneJournal(entree) { const detail = [ entree.description ? escapeHtml(entree.description) : '', entree.cible_table ? `
${escapeHtml(entree.cible_table)}${entree.cible_id ? ` #${Number(entree.cible_id)}` : ''}` : '', entree.contexte ? `
${escapeHtml(JSON.stringify(entree.contexte))}` : '', ].join(''); const classeResultat = entree.resultat === 'SUCCES' ? '' : ` dsi-journal-${entree.resultat.toLowerCase()}`; return ` ${formatDateHeure(entree.date_evenement)} ${escapeHtml(LIBELLES_CATEGORIE[entree.categorie] || entree.categorie)} ${escapeHtml(entree.acteur || 'Anonyme')}
${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role || '—')} ${escapeHtml(entree.action)} ${detail} ${escapeHtml(entree.adresse_ip || '—')} `; } /** Bandeau de chiffres au-dessus du journal. */ function afficherStatistiques(stats) { const cible = el('dsi-journal-stats'); if (!cible || !stats) return; const cartes = [ ['Entrées au total', stats.total], ['Connexions (24 h)', stats.connexions_24h], ['Refus (24 h)', stats.refus_24h], ['Écritures (7 j)', stats.sept_jours?.ECRITURE || 0], ]; cible.innerHTML = cartes.map(([libelle, valeur]) => `
${escapeHtml(libelle)}${Number(valeur || 0)}
`).join(''); } /** * Recharge le journal. * * @param {object} supplements Filtres imposés par le code (ex. id_compte * depuis le bouton « Journal » d'un compte), qui * s'ajoutent à ceux saisis dans l'écran. */ async function chargerJournal(supplements = {}) { const parametres = new URLSearchParams(); const champs = { categorie: el('dsi-journal-category')?.value || '', resultat: el('dsi-journal-result')?.value || '', role: el('dsi-journal-role')?.value || '', du: el('dsi-journal-from')?.value || '', au: el('dsi-journal-to')?.value || '', q: el('dsi-journal-search')?.value.trim() || '', }; Object.entries({ ...champs, ...supplements }).forEach(([cle, valeur]) => { if (valeur !== '' && valeur !== null && valeur !== undefined) parametres.set(cle, String(valeur)); }); parametres.set('page', String(pageJournal)); message('dsi-journal-message', 'Chargement du journal…'); try { const donnees = await api(`api/dsi_journal.php?${parametres.toString()}`); const entrees = donnees.entrees || []; el('dsi-journal-body').innerHTML = entrees.length ? entrees.map(ligneJournal).join('') : 'Aucune entrée pour ces filtres.'; afficherStatistiques(donnees.statistiques); // Le serveur peut ramener la page demandée à la dernière page réelle. pageJournal = Number(donnees.page || 1); el('dsi-journal-page-label').textContent = `Page ${pageJournal} sur ${Number(donnees.pages || 1)} — ${Number(donnees.total || 0)} entrée(s)`; el('dsi-journal-previous').disabled = pageJournal <= 1; el('dsi-journal-next').disabled = pageJournal >= Number(donnees.pages || 1); message('dsi-journal-message', ''); } catch (erreur) { message('dsi-journal-message', erreur.message, 'error'); } } /** Purge des entrées anciennes, confirmée puis journalisée côté serveur. */ async function purgerJournal(evenement) { evenement.preventDefault(); const jours = Number(el('dsi-purge-days').value); if (!window.confirm(`Supprimer définitivement les entrées de plus de ${jours} jours ?`)) return; try { const donnees = await poster('api/dsi_journal_purge.php', { jours }); message('dsi-journal-message', donnees.message, 'success'); pageJournal = 1; await chargerJournal(); } catch (erreur) { message('dsi-journal-message', erreur.message, 'error'); } } function init() { const params = window.PTA_PAGE_CONFIG?.params || {}; el('dsi-account-role')?.addEventListener('change', ajusterFormulaireCompte); el('dsi-account-form')?.addEventListener('submit', creerCompte); el('dsi-accounts-body')?.addEventListener('click', agirSurCompte); el('dsi-accounts-refresh')?.addEventListener('click', chargerComptes); el('dsi-filter-role')?.addEventListener('change', chargerComptes); el('dsi-filter-state')?.addEventListener('change', chargerComptes); el('dsi-filter-search')?.addEventListener('change', chargerComptes); el('dsi-generate-missing')?.addEventListener('click', genererManquants); el('dsi-token-copy')?.addEventListener('click', copierJetons); el('dsi-token-close')?.addEventListener('click', fermerJetons); ['dsi-journal-category', 'dsi-journal-result', 'dsi-journal-role', 'dsi-journal-from', 'dsi-journal-to', 'dsi-journal-search'] .forEach((identifiant) => el(identifiant)?.addEventListener('change', () => { pageJournal = 1; chargerJournal(); })); el('dsi-journal-refresh')?.addEventListener('click', () => chargerJournal()); el('dsi-journal-previous')?.addEventListener('click', () => { pageJournal = Math.max(1, pageJournal - 1); chargerJournal(); }); el('dsi-journal-next')?.addEventListener('click', () => { pageJournal += 1; chargerJournal(); }); el('dsi-purge-form')?.addEventListener('submit', purgerJournal); ajusterFormulaireCompte(); chargerComptes(); // Lien profond « journal de ce compte » venu d'une autre page. chargerJournal(params.compteId ? { id_compte: params.compteId } : {}); } PTA.modules.dsi = { init, chargerComptes, chargerJournal }; })();