feat(admin): modal de re-autorisation PIN (#52)
This commit is contained in:
parent
88e3cbc1bb
commit
60535bbe00
7 changed files with 268 additions and 8 deletions
|
|
@ -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'] : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ abstract class AdminController extends AuthenticatedController
|
|||
$context = [
|
||||
'currentUserName' => $info['name'],
|
||||
'currentUserRole' => $info['role_label'],
|
||||
'currentUserEmail' => $info['email'],
|
||||
'permissions' => $this->authorizer()->permissionsFor($roleId),
|
||||
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||
'activeNav' => '',
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ $navClass = static function (string $code, string $current): string {
|
|||
<title><?= $pageTitle ?></title>
|
||||
<link rel="stylesheet" href="/assets/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<body data-user-email="<?= htmlspecialchars($currentUserEmail ?? '', ENT_QUOTES, 'UTF-8') ?>">
|
||||
<div class="admin-layout">
|
||||
<header class="topbar">
|
||||
<div class="topbar-actions">
|
||||
|
|
@ -151,5 +151,6 @@ $navClass = static function (string $code, string $current): string {
|
|||
</main>
|
||||
</div>
|
||||
<script src="/assets/js/admin.js"></script>
|
||||
<script src="/assets/js/pin-modal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
139
src/public/admin/assets/js/pin-modal.js
Normal file
139
src/public/admin/assets/js/pin-modal.js
Normal 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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
83
tests/js/pin-modal.test.js
Normal file
83
tests/js/pin-modal.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue