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);
+});