403 lines
20 KiB
JavaScript
403 lines
20 KiB
JavaScript
/**
|
||
* 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;
|
||
})();
|