/** * 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) => `
${escapeHtml(entree.libelle)} ${escapeHtml(libelleRole(entree.role))}
${escapeHtml(entree.jeton)}
`).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 = [ `${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'); etats.push(...etatsSupplementaires(compte)); const connexions = compte.nombre_connexions > 0 ? `
${compte.nombre_connexions} connexion(s)` : ''; return ` ${identite} ${escapeHtml(libelleRole(compte.role))} ${escapeHtml(compte.jeton_prefixe)}… ${formatDateTime(compte.date_derniere_connexion)}${connexions} ${etats.join(' ')} ${actionsSupplementaires(compte)} `; } /** * 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 = '' + agents.map((agent) => ``).join(''); } if (selectLieu) { selectLieu.innerHTML = '' + lieux.map((lieu) => ``).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('') : 'Aucun compte ne correspond à ces filtres.'; 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; })();