From 60535bbe00593adcf33a2e96c5cecd4f5c1bce19 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 18 Jun 2026 13:17:59 +0200 Subject: [PATCH] feat(admin): modal de re-autorisation PIN (#52) --- src/app/Auth/UserDirectory.php | 5 +- src/app/Controllers/AdminController.php | 7 +- src/app/Views/admin/layout.php | 3 +- src/public/admin/assets/css/admin.css | 34 ++++++ src/public/admin/assets/js/pin-modal.js | 139 ++++++++++++++++++++++++ tests/Unit/Auth/UserDirectoryTest.php | 5 +- tests/js/pin-modal.test.js | 83 ++++++++++++++ 7 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 src/public/admin/assets/js/pin-modal.js create mode 100644 tests/js/pin-modal.test.js diff --git a/src/app/Auth/UserDirectory.php b/src/app/Auth/UserDirectory.php index 0a61da0..96af531 100644 --- a/src/app/Auth/UserDirectory.php +++ b/src/app/Auth/UserDirectory.php @@ -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 { $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', ['id' => $userId], ); @@ -35,6 +35,7 @@ final class UserDirectory return [ 'name' => $name !== '' ? $name : 'Utilisateur', 'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '', + 'email' => is_string($row['email'] ?? null) ? $row['email'] : '', ]; } } diff --git a/src/app/Controllers/AdminController.php b/src/app/Controllers/AdminController.php index 3ebf4bc..dce2a93 100644 --- a/src/app/Controllers/AdminController.php +++ b/src/app/Controllers/AdminController.php @@ -62,9 +62,10 @@ abstract class AdminController extends AuthenticatedController $info = $this->userDirectory()->displayInfo($userId); $context = [ - 'currentUserName' => $info['name'], - 'currentUserRole' => $info['role_label'], - 'permissions' => $this->authorizer()->permissionsFor($roleId), + 'currentUserName' => $info['name'], + 'currentUserRole' => $info['role_label'], + 'currentUserEmail' => $info['email'], + 'permissions' => $this->authorizer()->permissionsFor($roleId), 'csrfToken' => Csrf::token($this->sessionManager()), 'activeNav' => '', 'flash' => $this->takeFlash(), diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index 3289fa7..7e69f88 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -58,7 +58,7 @@ $navClass = static function (string $code, string $current): string { <?= $pageTitle ?> - +
@@ -151,5 +151,6 @@ $navClass = static function (string $code, string $current): string {
+ diff --git a/src/public/admin/assets/css/admin.css b/src/public/admin/assets/css/admin.css index 330b1e8..92d4b55 100644 --- a/src/public/admin/assets/css/admin.css +++ b/src/public/admin/assets/css/admin.css @@ -1358,3 +1358,37 @@ tbody td.mono { .feed-text b { font-weight: 800; } .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; } + +/* --- 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; } diff --git a/src/public/admin/assets/js/pin-modal.js b/src/public/admin/assets/js/pin-modal.js new file mode 100644 index 0000000..9aab54e --- /dev/null +++ b/src/public/admin/assets/js/pin-modal.js @@ -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 ) : 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 = + '
' + + '
' + + ' ' + + '
' + + '

Action a confirmer

' + + '

Saisissez vos identifiants equipier (ou ceux d\'un responsable).

' + + '
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + ' ' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
'; + 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); + }); + } +})(); diff --git a/tests/Unit/Auth/UserDirectoryTest.php b/tests/Unit/Auth/UserDirectoryTest.php index be0b993..e270d33 100644 --- a/tests/Unit/Auth/UserDirectoryTest.php +++ b/tests/Unit/Auth/UserDirectoryTest.php @@ -25,11 +25,12 @@ final class UserDirectoryTest extends TestCase $this->db->userDisplayRow = [ 'first_name' => 'Corentin', 'last_name' => 'J', + 'email' => 'corentin@wakdo.local', 'role_label' => 'Administrateur', ]; self::assertSame( - ['name' => 'Corentin J', 'role_label' => 'Administrateur'], + ['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local'], (new UserDirectory($this->db))->displayInfo(7), ); } @@ -39,7 +40,7 @@ final class UserDirectoryTest extends TestCase $this->db->userDisplayRow = null; self::assertSame( - ['name' => 'Utilisateur', 'role_label' => ''], + ['name' => 'Utilisateur', 'role_label' => '', 'email' => ''], (new UserDirectory($this->db))->displayInfo(999), ); } diff --git a/tests/js/pin-modal.test.js b/tests/js/pin-modal.test.js new file mode 100644 index 0000000..16917c4 --- /dev/null +++ b/tests/js/pin-modal.test.js @@ -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 , 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( + '' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + ' ' + + '
', + ); + 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); +});