/**
* 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) => `
`).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 `
`;
}
/**
* 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 `