Files
pta/assets/js/comptes-acces.js
2026-08-27 11:38:59 +02:00

403 lines
20 KiB
JavaScript
Raw Permalink 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.
/**
* Fabrique de l'écran « comptes d'accès » (`PTA.shared.ecranComptesAcces`).
*
* DEUX écrans distribuent des jetons, avec exactement le même bloc d'interface :
* - l'espace DSI (`dsi.php`), sur les quatre rôles, plus les mots de passe et
* le jeton d'installation ;
* - l'onglet Administration (`administration.php`), sur les seuls rôles AGENT
* et RESPONSABLE_STRUCTURE délégués au Service Enfance depuis la V7.27.
*
* Les deux modules en portaient chacun leur copie — panneau de jetons, tableau,
* formulaire de création, génération en lot — soit près de 300 lignes en double
* qui divergeaient déjà (le filtre de recherche n'était pas branché côté
* Administration). Tout cela vit ici, paramétré par un PRÉFIXE d'identifiants
* HTML : les deux partiels nomment leurs ancres à l'identique, `dsi-token-panel`
* d'un côté, `access-token-panel` de l'autre. Renommer une ancre dans un partiel
* sans la renommer dans l'autre casse la fabrique.
*
* Ce qui reste propre à un écran passe par les crochets de `reglages` : états et
* boutons supplémentaires sur une ligne, action inconnue de la fabrique, suite à
* donner après un chargement.
*
* RÈGLE CENTRALE, commune aux deux écrans : un jeton n'existe en clair que dans
* la réponse qui vient de le produire. Il est affiché dans un panneau dédié, qui
* reste ouvert tant qu'il n'a pas été refermé, et n'est JAMAIS rechargé depuis
* le serveur — la base n'en garde qu'une empreinte.
*/
(() => {
'use strict';
const PTA = window.PTA;
// core.js absent : la page reste lisible, simplement non interactive.
if (!PTA) return;
const { escapeHtml, apiJson, postJson, showMessageIn, formatDateTime } = PTA.utils;
/** Libellés des quatre rôles ; un écran n'en affiche que ceux qu'il liste. */
const LIBELLES_ROLE = {
DSI: 'DSI',
SERVICE_ENFANCE: 'Service Enfance',
RESPONSABLE_STRUCTURE: 'Responsable de structure',
AGENT: 'Agent',
};
/**
* 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. Les deux rôles de terrain sont décrits ici, les
* rôles privilégiés le sont par l'espace DSI, seul à les proposer.
*/
const AIDES_ROLE = {
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.',
};
const libelleRole = (role) => LIBELLES_ROLE[role] || role || '';
/**
* Construit l'écran des comptes pour un préfixe d'identifiants donné.
*
* @param {object} reglages
* @param {string} reglages.prefixe « dsi » ou « access » : préfixe de TOUTES les ancres HTML.
* @param {object} reglages.endpoints {liste, creer, jeton, statut, manquants}.
* @param {object} [reglages.aides] Aides de saisie à ajouter aux deux rôles de terrain.
* @param {boolean} [reglages.avecLibelle] Le formulaire porte le champ « Nom du titulaire »
* (rôles sans identité en base : Service Enfance, DSI).
* @param {string} [reglages.rappelJetons] Phrase ajoutée à l'avertissement du panneau de jetons.
* @param {Function} [reglages.etatsSupplementaires] (compte) => string[] de badges d'état.
* @param {Function} [reglages.actionsSupplementaires] (compte) => HTML de boutons en fin de ligne.
* @param {Function} [reglages.actionPersonnalisee] (evenement, contexte) => vrai s'il faut recharger.
* @param {Function} [reglages.apresChargement] (donnees) => void, après chaque chargement réussi.
* @returns {{attacher: Function, chargerComptes: Function, afficherJetons: Function}}
*/
function ecranComptesAcces(reglages) {
const {
prefixe,
endpoints,
aides = {},
avecLibelle = false,
rappelJetons = '',
etatsSupplementaires = () => [],
actionsSupplementaires = () => '',
actionPersonnalisee = null,
apresChargement = null,
} = reglages;
const aidesEcran = { ...AIDES_ROLE, ...aides };
/** Ancre HTML de cet écran : « account-role » → « dsi-account-role ». */
const id = (suffixe) => `${prefixe}-${suffixe}`;
const champ = (suffixe) => document.getElementById(id(suffixe));
const dire = (suffixe, texte, type) => showMessageIn(id(suffixe), texte, type);
// ------------------------------------------------------------- jetons
/**
* Affiche les jetons produits par la dernière action.
*
* @param {Array<{libelle: string, role: string, jeton: string}>} jetons
*/
function afficherJetons(jetons) {
const panneau = champ('token-panel');
const liste = champ('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(libelleRole(entree.role))}</small>
</div>
<code class="dsi-token-value">${escapeHtml(entree.jeton)}</code>
</div>
`).join('');
panneau.hidden = false;
dire('token-message', `${jetons.length} jeton(s) à transmettre. Ils ne seront plus affichés après fermeture.${rappelJetons}`, '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(`#${id('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();
}
dire('token-message', `${lignes.length} jeton(s) copié(s) dans le presse-papiers.`, 'success');
} catch (erreur) {
dire('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 = champ('token-panel');
if (!panneau) return;
panneau.hidden = true;
champ('token-list').innerHTML = '';
dire('token-message', '');
}
// ------------------------------------------------------------ comptes
/**
* Une ligne du tableau des comptes.
*
* Les classes d'action (`compte-regenerate`…) sont de simples points
* d'accroche pour la délégation de clic : aucune feuille de style ne les
* vise, la mise en forme passe par `dsi-actions-cell`.
*/
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>');
etats.push(...etatsSupplementaires(compte));
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(libelleRole(compte.role))}</td>
<td><code>${escapeHtml(compte.jeton_prefixe)}…</code></td>
<td>${formatDateTime(compte.date_derniere_connexion)}${connexions}</td>
<td>${etats.join(' ')}</td>
<td class="dsi-actions-cell">
<button type="button" class="button button-secondary compte-regenerate">Régénérer</button>
<button type="button" class="button button-secondary compte-toggle">${compte.actif ? 'Désactiver' : 'Réactiver'}</button>
${actionsSupplementaires(compte)}
<button type="button" class="button button-danger compte-delete">Supprimer</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 = champ('account-agent');
const selectLieu = champ('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 = champ('missing-count');
if (compteur) {
compteur.textContent = String(manquants.length);
compteur.hidden = manquants.length === 0;
}
const bouton = champ('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 = champ('filter-role')?.value || '';
const etat = champ('filter-state')?.value || '';
const recherche = champ('filter-search')?.value.trim() || '';
if (role) parametres.set('role', role);
if (etat !== '') parametres.set('actif', etat);
if (recherche) parametres.set('q', recherche);
dire('accounts-message', 'Chargement des comptes…');
try {
const donnees = await apiJson(`${endpoints.liste}?${parametres.toString()}`);
const comptes = donnees.comptes || [];
champ('accounts-body').innerHTML = comptes.length
? comptes.map(ligneCompte).join('')
: '<tr><td colspan="6">Aucun compte ne correspond à ces filtres.</td></tr>';
remplirIdentites(donnees.manquants || []);
apresChargement?.(donnees);
dire('accounts-message', `${comptes.length} compte(s) affiché(s).`, 'success');
} catch (erreur) {
dire('accounts-message', erreur.message, 'error');
}
}
/** Affiche les seuls champs d'identité utiles au type de compte choisi. */
function ajusterFormulaireCompte() {
const role = champ('account-role')?.value || '';
const champAgent = champ('account-agent-field');
const champLieu = champ('account-structure-field');
if (!champAgent || !champLieu) return;
champAgent.hidden = role !== 'AGENT';
champLieu.hidden = role !== 'RESPONSABLE_STRUCTURE';
champ('account-agent').required = role === 'AGENT';
champ('account-structure').required = role === 'RESPONSABLE_STRUCTURE';
// Service Enfance et DSI ne pointent vers aucune identité en base :
// leur seul identifiant lisible est le libellé saisi ici. Les écrans
// qui ne proposent pas ces rôles n'ont pas le champ du tout.
const champLibelle = avecLibelle ? champ('account-label-field') : null;
if (champLibelle) {
champLibelle.hidden = role === 'AGENT' || role === 'RESPONSABLE_STRUCTURE';
champ('account-label').required = !champLibelle.hidden;
}
const aide = champ('account-hint');
if (aide) aide.textContent = aidesEcran[role] || '';
}
/** Crée un compte, puis affiche son jeton — unique occasion de le lire. */
async function creerCompte(evenement) {
evenement.preventDefault();
const role = champ('account-role').value;
try {
const charge = {
role,
agent_id: role === 'AGENT' ? Number(champ('account-agent').value) : null,
structure_id: role === 'RESPONSABLE_STRUCTURE' ? Number(champ('account-structure').value) : null,
commentaire: champ('account-comment').value,
};
if (avecLibelle) charge.libelle = champ('account-label').value;
const donnees = await postJson(endpoints.creer, charge);
dire('account-message', donnees.message, 'success');
if (avecLibelle) champ('account-label').value = '';
champ('account-comment').value = '';
afficherJetons([{
libelle: donnees.compte?.identite_libelle || '',
role: donnees.compte?.role || role,
jeton: donnees.jeton,
}]);
await chargerComptes();
} catch (erreur) {
dire('account-message', erreur.message, 'error');
}
}
/**
* Régénération, activation, suppression : délégation sur le tableau.
*
* Les boutons propres à un écran ne sont pas reconnus ici : ils sont
* passés à `actionPersonnalisee`, qui répond si la liste doit être
* rechargée derrière.
*/
async function agirSurCompte(evenement) {
const ligne = evenement.target.closest('tr[data-compte-id]');
const bouton = evenement.target.closest('button');
if (!ligne || !bouton) return;
const compteId = Number(ligne.dataset.compteId);
const titulaire = ligne.querySelector('strong')?.textContent || 'ce compte';
try {
if (bouton.classList.contains('compte-regenerate')) {
if (!window.confirm(`Régénérer le jeton de ${titulaire} ? L’ancien cessera immédiatement de fonctionner.`)) return;
const donnees = await postJson(endpoints.jeton, { compte_id: compteId });
afficherJetons([{
libelle: donnees.compte?.identite_libelle || titulaire,
role: donnees.compte?.role || '',
jeton: donnees.jeton,
}]);
dire('accounts-message', donnees.message, 'success');
} else if (bouton.classList.contains('compte-toggle')) {
// Le libellé du bouton porte l'état courant : le tableau est
// la seule source, il n'y a pas d'état gardé en mémoire.
const action = bouton.textContent.trim() === 'Désactiver' ? 'DESACTIVER' : 'ACTIVER';
const donnees = await postJson(endpoints.statut, { compte_id: compteId, action });
dire('accounts-message', donnees.message, 'success');
} else if (bouton.classList.contains('compte-delete')) {
if (!window.confirm(`Supprimer définitivement le compte de ${titulaire} ? Son jeton cessera de fonctionner ; ses traces dans le journal sont conservées.`)) return;
const donnees = await postJson(endpoints.statut, { compte_id: compteId, action: 'SUPPRIMER' });
dire('accounts-message', donnees.message, 'success');
} else if (!await actionPersonnalisee?.(evenement, { bouton, compteId, titulaire })) {
return;
}
await chargerComptes();
} catch (erreur) {
dire('accounts-message', erreur.message, 'error');
}
}
/** Équipe d'un coup toutes les identités actives sans compte. */
async function genererManquants() {
const attendus = Number(champ('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 postJson(endpoints.manquants, {});
dire('accounts-message', donnees.message, 'success');
afficherJetons(donnees.jetons || []);
await chargerComptes();
} catch (erreur) {
dire('accounts-message', erreur.message, 'error');
}
}
/**
* Branche l'écran et effectue le premier chargement.
* @returns {boolean} Faux si la page ne porte pas le bloc des comptes —
* le cas de l'onglet Administration vu par un autre rôle.
*/
function attacher() {
if (!champ('accounts-body')) return false;
champ('account-role')?.addEventListener('change', ajusterFormulaireCompte);
champ('account-form')?.addEventListener('submit', creerCompte);
champ('accounts-body')?.addEventListener('click', agirSurCompte);
champ('accounts-refresh')?.addEventListener('click', chargerComptes);
champ('filter-role')?.addEventListener('change', chargerComptes);
champ('filter-state')?.addEventListener('change', chargerComptes);
champ('filter-search')?.addEventListener('change', chargerComptes);
champ('generate-missing')?.addEventListener('click', genererManquants);
champ('token-copy')?.addEventListener('click', copierJetons);
champ('token-close')?.addEventListener('click', fermerJetons);
ajusterFormulaireCompte();
chargerComptes();
return true;
}
return { attacher, chargerComptes, afficherJetons };
}
PTA.shared.ecranComptesAcces = ecranComptesAcces;
})();