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
|
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'] : '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ abstract class AdminController extends AuthenticatedController
|
||||||
$context = [
|
$context = [
|
||||||
'currentUserName' => $info['name'],
|
'currentUserName' => $info['name'],
|
||||||
'currentUserRole' => $info['role_label'],
|
'currentUserRole' => $info['role_label'],
|
||||||
|
'currentUserEmail' => $info['email'],
|
||||||
'permissions' => $this->authorizer()->permissionsFor($roleId),
|
'permissions' => $this->authorizer()->permissionsFor($roleId),
|
||||||
'csrfToken' => Csrf::token($this->sessionManager()),
|
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||||
'activeNav' => '',
|
'activeNav' => '',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
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 = [
|
$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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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