Files
pta/assets/js/dsi.js
2026-08-20 17:00:06 +02:00

530 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) => `
<div class="dsi-token-row">
<div class="dsi-token-identity">
<strong>${escapeHtml(entree.libelle)}</strong>
<small>${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role)}</small>
</div>
<code class="dsi-token-value">${escapeHtml(entree.jeton)}</code>
</div>
`).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 = [
`<strong>${escapeHtml(compte.identite_libelle)}</strong>`,
compte.matricule ? `<br><small>Matricule ${escapeHtml(compte.matricule)}</small>` : '',
compte.commentaire ? `<br><small>${escapeHtml(compte.commentaire)}</small>` : '',
].join('');
const etats = [];
if (!compte.actif) etats.push('<span class="dsi-state dsi-state-off">Désactivé</span>');
else if (!compte.identite_active) etats.push('<span class="dsi-state dsi-state-off">Identité inactive</span>');
else etats.push('<span class="dsi-state dsi-state-on">Actif</span>');
if (compte.jeton_installation) etats.push('<span class="dsi-state dsi-state-alert">Jeton d’installation</span>');
// 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
? '<span class="dsi-state dsi-state-on">Mot de passe défini</span>'
: '<span class="dsi-state dsi-state-wait">Mot de passe à définir</span>');
}
const connexions = compte.nombre_connexions > 0
? `<br><small>${compte.nombre_connexions} connexion(s)</small>`
: '';
return `
<tr data-compte-id="${Number(compte.id_compte)}">
<td>${identite}</td>
<td>${escapeHtml(LIBELLES_ROLE[compte.role] || compte.role)}</td>
<td><code>${escapeHtml(compte.jeton_prefixe)}…</code></td>
<td>${formatDateHeure(compte.date_derniere_connexion)}${connexions}</td>
<td>${etats.join(' ')}</td>
<td class="dsi-actions-cell">
<button type="button" class="button button-secondary dsi-regenerate">Régénérer</button>
<button type="button" class="button button-secondary dsi-toggle">${compte.actif ? 'Désactiver' : 'Réactiver'}</button>
${compte.mot_de_passe_requis && compte.mot_de_passe_defini
? '<button type="button" class="button button-secondary dsi-reset-password">Effacer le mot de passe</button>'
: ''}
<button type="button" class="button button-danger dsi-delete">Supprimer</button>
<button type="button" class="button button-secondary dsi-see-journal">Journal</button>
</td>
</tr>`;
}
/**
* 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 = '<option value="">Choisir un agent sans compte</option>'
+ agents.map((agent) => `<option value="${Number(agent.id_agent)}">${escapeHtml(agent.libelle)}</option>`).join('');
}
if (selectLieu) {
selectLieu.innerHTML = '<option value="">Choisir un lieu sans compte</option>'
+ lieux.map((lieu) => `<option value="${Number(lieu.id_structure)}">${escapeHtml(lieu.libelle)}</option>`).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('')
: '<tr><td colspan="6">Aucun compte ne correspond à ces filtres.</td></tr>';
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
? `<br><small>${escapeHtml(entree.cible_table)}${entree.cible_id ? ` #${Number(entree.cible_id)}` : ''}</small>`
: '',
entree.contexte
? `<br><small class="dsi-journal-context">${escapeHtml(JSON.stringify(entree.contexte))}</small>`
: '',
].join('');
const classeResultat = entree.resultat === 'SUCCES' ? '' : ` dsi-journal-${entree.resultat.toLowerCase()}`;
return `
<tr class="dsi-journal-row${classeResultat}">
<td>${formatDateHeure(entree.date_evenement)}</td>
<td>${escapeHtml(LIBELLES_CATEGORIE[entree.categorie] || entree.categorie)}</td>
<td>${escapeHtml(entree.acteur || 'Anonyme')}<br><small>${escapeHtml(LIBELLES_ROLE[entree.role] || entree.role || '—')}</small></td>
<td><code>${escapeHtml(entree.action)}</code></td>
<td>${detail}</td>
<td>${escapeHtml(entree.adresse_ip || '—')}</td>
</tr>`;
}
/** 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]) => `
<article class="summary-card"><span>${escapeHtml(libelle)}</span><strong>${Number(valeur || 0)}</strong></article>
`).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('')
: '<tr><td colspan="6">Aucune entrée pour ces filtres.</td></tr>';
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 };
})();