feat(admin): modal de re-autorisation PIN au moment de l'action sensible
Some checks failed
CI / static-tests (push) Successful in 2m18s
CI / js-tests (push) Successful in 49s
CI / secret-scan (pull_request) Successful in 18s
CI / secret-scan (push) Successful in 37s
CI / php-lint (push) Successful in 52s
CI / php-lint (pull_request) Successful in 22s
CI / static-tests (pull_request) Successful in 1m30s
CI / js-tests (pull_request) Successful in 36s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Failing after 5s

Les formulaires d'action sensible (RG-T13) portaient un fieldset PIN inline noye en bas
de page (email equipier + PIN), peu clair pour un equipier non technique. pin-modal.js
(CSP-safe, auto-detecte les formulaires via #pin_email) masque ce fieldset et fait surgir
un modal au clic sur l'action, avec l'email de l'utilisateur connecte pre-rempli (expose
via UserDirectory + <body data-user-email>) et le PIN a saisir. Contrat serveur inchange
(il lit toujours pin_email + pin), aucune modif des 8 formulaires concernes.

Tests : 3 tests jsdom (masquage, ouverture, prefill, confirmation, refus si vide) ;
UserDirectoryTest mis a jour (email). PHPUnit 301 + PHPStan L6 verts.
This commit is contained in:
Imugiii 2026-06-18 11:04:53 +00:00
parent 88e3cbc1bb
commit b6dfc2a56c
7 changed files with 268 additions and 8 deletions

View file

@ -18,12 +18,12 @@ final class UserDirectory
} }
/** /**
* @return array{name: string, role_label: string} * @return array{name: string, role_label: string, email: string}
*/ */
public function displayInfo(int $userId): array public function displayInfo(int $userId): array
{ {
$row = $this->db->fetch( $row = $this->db->fetch(
'SELECT u.first_name, u.last_name, r.label AS role_label ' 'SELECT u.first_name, u.last_name, u.email, r.label AS role_label '
. 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id', . 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id',
['id' => $userId], ['id' => $userId],
); );
@ -35,6 +35,7 @@ final class UserDirectory
return [ return [
'name' => $name !== '' ? $name : 'Utilisateur', 'name' => $name !== '' ? $name : 'Utilisateur',
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '', 'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
]; ];
} }
} }

View file

@ -62,9 +62,10 @@ abstract class AdminController extends AuthenticatedController
$info = $this->userDirectory()->displayInfo($userId); $info = $this->userDirectory()->displayInfo($userId);
$context = [ $context = [
'currentUserName' => $info['name'], 'currentUserName' => $info['name'],
'currentUserRole' => $info['role_label'], 'currentUserRole' => $info['role_label'],
'permissions' => $this->authorizer()->permissionsFor($roleId), 'currentUserEmail' => $info['email'],
'permissions' => $this->authorizer()->permissionsFor($roleId),
'csrfToken' => Csrf::token($this->sessionManager()), 'csrfToken' => Csrf::token($this->sessionManager()),
'activeNav' => '', 'activeNav' => '',
'flash' => $this->takeFlash(), 'flash' => $this->takeFlash(),

View file

@ -58,7 +58,7 @@ $navClass = static function (string $code, string $current): string {
<title><?= $pageTitle ?></title> <title><?= $pageTitle ?></title>
<link rel="stylesheet" href="/assets/css/admin.css"> <link rel="stylesheet" href="/assets/css/admin.css">
</head> </head>
<body> <body data-user-email="<?= htmlspecialchars($currentUserEmail ?? '', ENT_QUOTES, 'UTF-8') ?>">
<div class="admin-layout"> <div class="admin-layout">
<header class="topbar"> <header class="topbar">
<div class="topbar-actions"> <div class="topbar-actions">
@ -151,5 +151,6 @@ $navClass = static function (string $code, string $current): string {
</main> </main>
</div> </div>
<script src="/assets/js/admin.js"></script> <script src="/assets/js/admin.js"></script>
<script src="/assets/js/pin-modal.js"></script>
</body> </body>
</html> </html>

View file

@ -1358,3 +1358,37 @@ tbody td.mono {
.feed-text b { font-weight: 800; } .feed-text b { font-weight: 800; }
.feed-meta { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; } .feed-meta { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; }
.feed-time { font-size: 13px; color: var(--color-text-muted); font-weight: 600; white-space: nowrap; } .feed-time { font-size: 13px; color: var(--color-text-muted); font-weight: 600; white-space: nowrap; }
/* --- Modal PIN (re-autorisation au moment de l'action sensible) --- */
.pin-modal-overlay {
position: fixed;
inset: 0;
background: rgba(26, 26, 26, 0.45);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 24px;
}
.pin-modal-overlay.open { display: flex; }
.pin-modal {
background: var(--color-white);
border-radius: var(--radius-card);
border-top: 3px solid var(--color-yellow);
box-shadow: var(--shadow-card-hover);
width: 100%;
max-width: 400px;
padding: 28px;
}
.pin-modal-head { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; }
.pin-modal-ico {
width: 44px; height: 44px; flex-shrink: 0;
border-radius: 13px;
background: var(--color-yellow-soft);
color: var(--color-yellow-ink);
display: flex; align-items: center; justify-content: center;
}
.pin-modal-title { font-size: 18px; font-weight: 800; letter-spacing: -0.3px; }
.pin-modal-sub { font-size: 13px; color: var(--color-text-muted); margin-top: 3px; }
.pin-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }

View file

@ -0,0 +1,139 @@
/**
* pin-modal.js Re-autorisation par PIN au moment de l'action sensible.
*
* Les formulaires d'action sensible portent un fieldset inline (email equipier + PIN,
* RG-T13). Plutot que ce bloc noye en bas du formulaire, on le masque et on le remplace
* par un MODAL clair qui surgit au clic sur "Enregistrer/Supprimer" : l'equipier confirme
* avec son email + PIN (ou ceux d'un responsable), on reinjecte dans les champs caches,
* puis on soumet. Le contrat serveur ne change pas (il lit toujours pin_email + pin).
*
* CSP 'self' : script externe, aucun handler inline, le DOM du modal est construit ici.
*/
(function () {
'use strict';
function init(doc) {
var emailInput = doc.getElementById('pin_email');
var pinInput = doc.getElementById('pin');
// Seuls les formulaires de RE-AUTORISATION ont pin_email (la page set-PIN ne
// l'a pas : on ne l'intercepte donc pas).
if (!emailInput || !pinInput) {
return;
}
var form = pinInput.closest('form');
if (!form) {
return;
}
var fieldset = pinInput.closest('fieldset');
if (fieldset) {
fieldset.hidden = true;
}
// Email de l'utilisateur connecte (expose sur <body data-user-email>) : pre-remplit
// le modal pour le cas courant ou l'on valide sa PROPRE action ; reste modifiable
// pour validation par un responsable.
var prefillEmail = (doc.body && doc.body.getAttribute('data-user-email')) || '';
var overlay = buildModal(doc);
doc.body.appendChild(overlay);
var modalEmail = overlay.querySelector('#pm-email');
var modalPin = overlay.querySelector('#pm-pin');
var modalError = overlay.querySelector('[data-pm-error]');
var confirmed = false;
form.addEventListener('submit', function (e) {
if (confirmed) {
return; // deja valide via le modal -> soumission reelle
}
e.preventDefault();
openModal();
});
overlay.querySelector('[data-pm-cancel]').addEventListener('click', closeModal);
overlay.addEventListener('mousedown', function (e) {
if (e.target === overlay) {
closeModal();
}
});
doc.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.classList.contains('open')) {
closeModal();
}
});
overlay.querySelector('[data-pm-form]').addEventListener('submit', function (e) {
e.preventDefault();
var email = modalEmail.value.trim();
var pin = modalPin.value;
if (email === '' || pin === '') {
modalError.textContent = 'Email et PIN requis pour confirmer.';
modalError.hidden = false;
return;
}
emailInput.value = email;
pinInput.value = pin;
confirmed = true;
closeModal();
form.submit();
});
function openModal() {
modalError.hidden = true;
modalEmail.value = emailInput.value || prefillEmail || '';
modalPin.value = '';
overlay.classList.add('open');
(modalEmail.value === '' ? modalEmail : modalPin).focus();
}
function closeModal() {
overlay.classList.remove('open');
}
}
function buildModal(doc) {
var overlay = doc.createElement('div');
overlay.className = 'pin-modal-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Confirmation par PIN');
overlay.innerHTML =
'<div class="pin-modal">' +
' <div class="pin-modal-head">' +
' <span class="pin-modal-ico" aria-hidden="true">' +
' <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>' +
' </span>' +
' <div>' +
' <h2 class="pin-modal-title">Action a confirmer</h2>' +
' <p class="pin-modal-sub">Saisissez vos identifiants equipier (ou ceux d\'un responsable).</p>' +
' </div>' +
' </div>' +
' <form data-pm-form novalidate>' +
' <div class="form-group">' +
' <label class="form-label" for="pm-email">Email equipier</label>' +
' <input class="form-input" type="email" id="pm-email" autocomplete="off">' +
' </div>' +
' <div class="form-group">' +
' <label class="form-label" for="pm-pin">PIN</label>' +
' <input class="form-input" type="password" id="pm-pin" inputmode="numeric" autocomplete="off">' +
' </div>' +
' <p class="form-error" data-pm-error hidden></p>' +
' <div class="pin-modal-actions">' +
' <button class="btn btn-secondary" type="button" data-pm-cancel>Annuler</button>' +
' <button class="btn btn-primary" type="submit">Confirmer</button>' +
' </div>' +
' </form>' +
'</div>';
return overlay;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { init: init, buildModal: buildModal };
}
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('DOMContentLoaded', function () {
init(document);
});
}
})();

View file

@ -25,11 +25,12 @@ final class UserDirectoryTest extends TestCase
$this->db->userDisplayRow = [ $this->db->userDisplayRow = [
'first_name' => 'Corentin', 'first_name' => 'Corentin',
'last_name' => 'J', 'last_name' => 'J',
'email' => 'corentin@wakdo.local',
'role_label' => 'Administrateur', 'role_label' => 'Administrateur',
]; ];
self::assertSame( self::assertSame(
['name' => 'Corentin J', 'role_label' => 'Administrateur'], ['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local'],
(new UserDirectory($this->db))->displayInfo(7), (new UserDirectory($this->db))->displayInfo(7),
); );
} }
@ -39,7 +40,7 @@ final class UserDirectoryTest extends TestCase
$this->db->userDisplayRow = null; $this->db->userDisplayRow = null;
self::assertSame( self::assertSame(
['name' => 'Utilisateur', 'role_label' => ''], ['name' => 'Utilisateur', 'role_label' => '', 'email' => ''],
(new UserDirectory($this->db))->displayInfo(999), (new UserDirectory($this->db))->displayInfo(999),
); );
} }

View file

@ -0,0 +1,83 @@
/*
* Tests du modal de re-autorisation PIN du back-office (node:test + jsdom).
*
* Couvre : masquage du fieldset inline, ouverture du modal a la soumission d'un
* formulaire d'action sensible (pas de soumission reelle), pre-remplissage de
* l'email depuis <body data-user-email>, et la confirmation qui reinjecte
* email + PIN dans les champs caches puis soumet. DOM simule par jsdom.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
// pin-modal.js est du CommonJS (admin = racine CommonJS) ; import par defaut.
import pinModal from '../../src/public/admin/assets/js/pin-modal.js';
function setup(email) {
const dom = new JSDOM(
'<!DOCTYPE html><html><body data-user-email="' + email + '">' +
'<form id="f" method="post" action="/admin/roles/1/update">' +
' <fieldset id="pinfs">' +
' <input type="email" id="pin_email" name="pin_email">' +
' <input type="password" id="pin" name="pin">' +
' </fieldset>' +
' <button type="submit">Enregistrer</button>' +
'</form></body></html>',
);
return dom;
}
function fireSubmit(dom, el) {
el.dispatchEvent(new dom.window.Event('submit', { cancelable: true, bubbles: true }));
}
test('init masque le fieldset inline et insere un modal ferme', () => {
const dom = setup('a@b.c');
pinModal.init(dom.window.document);
const doc = dom.window.document;
assert.equal(doc.getElementById('pinfs').hidden, true);
assert.ok(doc.querySelector('.pin-modal-overlay'));
assert.equal(doc.querySelector('.pin-modal-overlay.open'), null);
});
test('soumettre le formulaire ouvre le modal (sans soumission reelle) et pre-remplit l email', () => {
const dom = setup('manager@wakdo.local');
const doc = dom.window.document;
pinModal.init(doc);
const form = doc.getElementById('f');
let submitted = false;
form.submit = () => { submitted = true; };
fireSubmit(dom, form);
assert.equal(doc.querySelector('.pin-modal-overlay').classList.contains('open'), true);
assert.equal(submitted, false);
assert.equal(doc.getElementById('pm-email').value, 'manager@wakdo.local');
});
test('confirmer reinjecte email + PIN et soumet ; refuse si champ vide', () => {
const dom = setup('a@b.c');
const doc = dom.window.document;
pinModal.init(doc);
const form = doc.getElementById('f');
let submitted = false;
form.submit = () => { submitted = true; };
fireSubmit(dom, form);
const modalForm = doc.querySelector('[data-pm-form]');
// PIN vide -> pas de soumission, erreur affichee.
doc.getElementById('pm-pin').value = '';
fireSubmit(dom, modalForm);
assert.equal(submitted, false);
assert.equal(doc.querySelector('[data-pm-error]').hidden, false);
// Email + PIN -> reinjection + soumission.
doc.getElementById('pm-email').value = 'valid@wakdo.local';
doc.getElementById('pm-pin').value = '4729';
fireSubmit(dom, modalForm);
assert.equal(doc.getElementById('pin_email').value, 'valid@wakdo.local');
assert.equal(doc.getElementById('pin').value, '4729');
assert.equal(submitted, true);
assert.equal(doc.querySelector('.pin-modal-overlay').classList.contains('open'), false);
});