Files
pta/assets/js/access-tokens.js
2026-08-25 15:12:11 +02:00

346 lines
16 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.
/**
* Jetons d'accès de terrain (`PTA.modules.accessTokens`), écran administration.php
* — V7.27.
*
* Le Service Enfance équipe ses agents et ses responsables de lieu : création
* d'un compte, régénération d'un jeton perdu, désactivation, suppression, et
* génération en lot pour les identités qui n'en ont pas encore.
*
* PÉRIMÈTRE : agents et responsables de lieu, RIEN D'AUTRE. Les comptes Service
* Enfance et DSI ne sont ni listés ni créables. Ce module ne les propose pas, et
* le serveur les refuse en 403 (AccessAccountController) : l'écran ne fait que
* refléter une frontière qu'il ne décide pas.
*
* RÈGLE CENTRALE, héritée de l'espace DSI : 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 { readJsonResponse, apiErrorMessage, escapeHtml } = PTA.utils;
const el = (id) => document.getElementById(id);
const LIBELLES_ROLE = {
RESPONSABLE_STRUCTURE: 'Responsable de structure',
AGENT: 'Agent',
};
/**
* Ce qu'il faut renseigner change selon le type de compte : l'écran
* l'explique plutôt que de laisser deviner pourquoi un champ change.
*/
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.',
};
/** Message dans un des bandeaux de la section. */
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é : toute réponse non-OK devient une exception. */
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 dans le 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 » → « 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('access-token-panel');
const liste = el('access-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('access-token-message', `${jetons.length} jeton(s) à transmettre. Ils ne seront plus affichés après fermeture.`, '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('#access-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('access-token-message', `${lignes.length} jeton(s) copié(s) dans le presse-papiers.`, 'success');
} catch (erreur) {
message('access-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('access-token-panel');
if (!panneau) return;
panneau.hidden = true;
el('access-token-list').innerHTML = '';
message('access-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>');
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 access-regenerate">Régénérer</button>
<button type="button" class="button button-secondary access-toggle">${compte.actif ? 'Désactiver' : 'Réactiver'}</button>
<button type="button" class="button button-danger access-delete">Supprimer</button>
</td>
</tr>`;
}
/**
* Remplit les listes 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('access-account-agent');
const selectLieu = el('access-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('access-missing-count');
if (compteur) {
compteur.textContent = String(manquants.length);
compteur.hidden = manquants.length === 0;
}
const bouton = el('access-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('access-filter-role')?.value || '';
const etat = el('access-filter-state')?.value || '';
const recherche = el('access-filter-search')?.value.trim() || '';
if (role) parametres.set('role', role);
if (etat !== '') parametres.set('actif', etat);
if (recherche) parametres.set('q', recherche);
message('access-accounts-message', 'Chargement des comptes…');
try {
const donnees = await api(`api/acces_comptes.php?${parametres.toString()}`);
const comptes = donnees.comptes || [];
el('access-accounts-body').innerHTML = comptes.length
? comptes.map(ligneCompte).join('')
: '<tr><td colspan="6">Aucun compte ne correspond à ces filtres.</td></tr>';
remplirIdentites(donnees.manquants || []);
message('access-accounts-message', `${comptes.length} compte(s) affiché(s).`, 'success');
} catch (erreur) {
message('access-accounts-message', erreur.message, 'error');
}
}
/** Affiche les seuls champs d'identité utiles au type de compte choisi. */
function ajusterFormulaireCompte() {
const role = el('access-account-role')?.value || '';
const champAgent = el('access-account-agent-field');
const champLieu = el('access-account-structure-field');
if (!champAgent || !champLieu) return;
champAgent.hidden = role !== 'AGENT';
champLieu.hidden = role !== 'RESPONSABLE_STRUCTURE';
el('access-account-agent').required = role === 'AGENT';
el('access-account-structure').required = role === 'RESPONSABLE_STRUCTURE';
const aide = el('access-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('access-account-role').value;
try {
const donnees = await poster('api/acces_compte_creer.php', {
role,
agent_id: role === 'AGENT' ? Number(el('access-account-agent').value) : null,
structure_id: role === 'RESPONSABLE_STRUCTURE' ? Number(el('access-account-structure').value) : null,
commentaire: el('access-account-comment').value,
});
message('access-account-message', donnees.message, 'success');
el('access-account-comment').value = '';
afficherJetons([{
libelle: donnees.compte?.identite_libelle || '',
role: donnees.compte?.role || role,
jeton: donnees.jeton,
}]);
await chargerComptes();
} catch (erreur) {
message('access-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('.access-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/acces_compte_jeton.php', { compte_id: compteId });
afficherJetons([{
libelle: donnees.compte?.identite_libelle || titulaire,
role: donnees.compte?.role || '',
jeton: donnees.jeton,
}]);
message('access-accounts-message', donnees.message, 'success');
} else if (evenement.target.closest('.access-toggle')) {
const desactiver = evenement.target.textContent.trim().startsWith('Dés');
const donnees = await poster('api/acces_compte_statut.php', {
compte_id: compteId,
action: desactiver ? 'DESACTIVER' : 'ACTIVER',
});
message('access-accounts-message', donnees.message, 'success');
} else if (evenement.target.closest('.access-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 poster('api/acces_compte_statut.php', { compte_id: compteId, action: 'SUPPRIMER' });
message('access-accounts-message', donnees.message, 'success');
} else {
return;
}
await chargerComptes();
} catch (erreur) {
message('access-accounts-message', erreur.message, 'error');
}
}
/** Équipe d'un coup toutes les identités actives sans compte. */
async function genererManquants() {
if (!window.confirm('Générer un jeton pour chaque agent et chaque lieu actif qui n’en a pas ? Ils seront affichés une seule fois.')) return;
try {
const donnees = await poster('api/acces_comptes_manquants.php', {});
message('access-accounts-message', donnees.message, 'success');
afficherJetons(donnees.jetons || []);
await chargerComptes();
} catch (erreur) {
message('access-accounts-message', erreur.message, 'error');
}
}
function init() {
// La section n'existe que sur l'écran Administration : ailleurs, le
// module se retire sans rien tenter.
if (!el('access-accounts-body')) return;
el('access-account-role')?.addEventListener('change', ajusterFormulaireCompte);
el('access-account-form')?.addEventListener('submit', creerCompte);
el('access-accounts-body')?.addEventListener('click', agirSurCompte);
el('access-accounts-refresh')?.addEventListener('click', chargerComptes);
el('access-filter-role')?.addEventListener('change', chargerComptes);
el('access-filter-state')?.addEventListener('change', chargerComptes);
el('access-generate-missing')?.addEventListener('click', genererManquants);
el('access-token-copy')?.addEventListener('click', copierJetons);
el('access-token-close')?.addEventListener('click', fermerJetons);
ajusterFormulaireCompte();
chargerComptes();
}
PTA.modules.accessTokens = { init, refresh: chargerComptes };
})();