Compare commits

...

2 commits

Author SHA1 Message Date
Imugiii
67fe086581 feat(admin): ecran Roles humanise (francais, droits groupes, listes deroulantes)
Some checks failed
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 53s
CI / js-tests (push) Successful in 32s
CI / secret-scan (pull_request) Successful in 10s
CI / php-lint (pull_request) Successful in 24s
CI / static-tests (pull_request) Successful in 47s
CI / js-tests (pull_request) Successful in 27s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Failing after 5s
Le formulaire et la liste des roles etaient tres techniques (libelles anglais, codes
de permission bruts, enums kiosk/counter/drive, 'route par defaut (landing)'). Refonte
de presentation (option a, la base garde les codes) :
- Champs relabeles + aides : Nom du role, Code interne, Page d'accueil (liste deroulante
  de pages), Canal de commande (Borne/Comptoir/Drive), Canaux visibles.
- Droits d'acces : matrice regroupee par domaine (Produits/Menus/Stock/Commandes/Comptes/
  Roles & statistiques...), libelles francais (Voir/Creer/Modifier/Supprimer...), codes masques.
- Liste des roles : entetes + valeurs (page d'accueil, canal) en clair.
- admin.css : .perm-grid/.perm-group + reset fieldset/legend.
Noms de champs postes inchanges (contrat serveur intact). PHPUnit 301 + PHPStan L6 verts.
2026-06-18 11:32:50 +00:00
Imugiii
b6dfc2a56c 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.
2026-06-18 11:04:53 +00:00
9 changed files with 457 additions and 53 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
{
$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'] : '',
];
}
}

View file

@ -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(),

View file

@ -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>

View file

@ -9,6 +9,10 @@ declare(strict_types=1);
* donc pas de `name[]` ni de JS. Toute soumission exige le PIN equipier (RG-T13).
* Le `code` est editable a la creation, fige a l'edition (immuable).
*
* Presentation humanisee (option a) : les permissions sont regroupees par domaine
* et libellees en francais ICI (la base reste la source des codes) ; canal et page
* d'accueil sont des listes deroulantes. Les NOMS de champs postes sont inchanges.
*
* @var int $roleId
* @var bool $isAdminRole
* @var array<int, array<string, mixed>> $permissions catalogue {id, code, label}
@ -41,11 +45,79 @@ $val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ??
$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? htmlspecialchars($errs[$k], ENT_QUOTES, 'UTF-8') : '';
$selectedSource = (string) ($vals['order_source'] ?? '');
$active = (bool) ($vals['is_active'] ?? true);
// --- Correspondances humaines (presentation seule) ---
// Canal de commande : enum technique -> libelle parlant.
$canalLabels = ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive'];
$canalLabel = static fn (string $enum): string => $canalLabels[$enum] ?? $enum;
// Pages proposees comme page d'accueil (liste deroulante) : chemin -> libelle.
$routeOptions = [
'/admin/dashboard' => 'Tableau de bord',
'/admin/stats' => 'Statistiques',
'/admin/products' => 'Produits',
'/admin/menus' => 'Menus',
'/admin/ingredients' => 'Stock',
'/admin/categories' => 'Categories',
'/admin/users' => 'Comptes',
'/admin/roles' => 'Roles',
];
$currentRoute = (string) ($vals['default_route'] ?? '');
// Toujours pouvoir reselectionner la valeur courante meme si hors liste (ex. seed).
if ($currentRoute !== '' && !isset($routeOptions[$currentRoute])) {
$routeOptions[$currentRoute] = $currentRoute;
}
// Permissions : code technique -> [groupe, action]. La base reste la source des codes.
$permMap = [
'product.read' => ['Produits', 'Voir'],
'product.create' => ['Produits', 'Creer'],
'product.update' => ['Produits', 'Modifier'],
'product.delete' => ['Produits', 'Supprimer'],
'menu.read' => ['Menus', 'Voir'],
'menu.create' => ['Menus', 'Creer'],
'menu.update' => ['Menus', 'Modifier'],
'menu.delete' => ['Menus', 'Supprimer'],
'category.manage' => ['Catalogue & recettes', 'Gerer les categories'],
'ingredient.manage' => ['Catalogue & recettes', 'Gerer les ingredients et recettes'],
'stock.read' => ['Stock', 'Voir'],
'stock.count' => ['Stock', "Faire l'inventaire"],
'stock.manage' => ['Stock', 'Reapprovisionner'],
'order.read' => ['Commandes', 'Voir'],
'order.create' => ['Commandes', 'Creer'],
'order.deliver' => ['Commandes', 'Livrer'],
'order.cancel' => ['Commandes', 'Annuler'],
'user.read' => ['Comptes', 'Voir'],
'user.create' => ['Comptes', 'Creer'],
'user.update' => ['Comptes', 'Modifier'],
'user.deactivate' => ['Comptes', 'Desactiver'],
'role.manage' => ['Roles & statistiques', 'Gerer les roles'],
'stats.read' => ['Roles & statistiques', 'Voir les statistiques'],
];
$groupOrder = ['Produits', 'Menus', 'Catalogue & recettes', 'Stock', 'Commandes', 'Comptes', 'Roles & statistiques', 'Autres'];
// Regroupe le catalogue recu par domaine humain.
$grouped = [];
foreach ($perms as $p) {
$code = (string) ($p['code'] ?? '');
$map = $permMap[$code] ?? ['Autres', (string) ($p['label'] ?? $code)];
$grouped[$map[0]][] = [
'id' => (int) ($p['id'] ?? 0),
'action' => $map[1],
'checked' => in_array((int) ($p['id'] ?? 0), $selPerms, true),
];
}
?>
<div class="page-header">
<div>
<h1 class="page-title"><?= $id !== 0 ? 'Modifier le role' : 'Nouveau role' ?></h1>
<?php if ($isAdmin): ?><p class="page-subtitle">Role administrateur : il doit conserver <code>role.manage</code> et rester actif.</p><?php endif; ?>
<p class="page-subtitle">
<?php if ($isAdmin): ?>
Role administrateur : il doit garder le droit de gerer les roles et rester actif.
<?php else: ?>
Definissez ce que ce role peut faire dans le back-office.
<?php endif; ?>
</p>
</div>
</div>
@ -53,20 +125,21 @@ $active = (bool) ($vals['is_active'] ?? true);
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="code">Code</label>
<?php if ($id === 0): ?>
<input class="form-input" type="text" id="code" name="code" maxlength="40" value="<?= $val('code') ?>" required>
<?php if ($err('code') !== ''): ?><p class="form-error"><?= $err('code') ?></p><?php endif; ?>
<?php else: ?>
<input class="form-input" type="text" id="code" value="<?= $val('code') ?>" disabled>
<p><small class="muted">Le code est immuable apres creation.</small></p>
<?php endif; ?>
<label class="form-label" for="label">Nom du role</label>
<input class="form-input" type="text" id="label" name="label" maxlength="80" value="<?= $val('label') ?>" required>
<?php if ($err('label') !== ''): ?><p class="form-error"><?= $err('label') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="label">Libelle</label>
<input class="form-input" type="text" id="label" name="label" maxlength="80" value="<?= $val('label') ?>" required>
<?php if ($err('label') !== ''): ?><p class="form-error"><?= $err('label') ?></p><?php endif; ?>
<label class="form-label" for="code">Code interne</label>
<?php if ($id === 0): ?>
<input class="form-input" type="text" id="code" name="code" maxlength="40" value="<?= $val('code') ?>" required>
<p class="form-helper">Identifiant technique (sans espace), non modifiable apres creation.</p>
<?php if ($err('code') !== ''): ?><p class="form-error"><?= $err('code') ?></p><?php endif; ?>
<?php else: ?>
<input class="form-input" type="text" id="code" value="<?= $val('code') ?>" disabled>
<p class="form-helper">Identifiant technique, non modifiable apres creation.</p>
<?php endif; ?>
</div>
<div class="form-group">
@ -75,60 +148,69 @@ $active = (bool) ($vals['is_active'] ?? true);
</div>
<div class="form-group">
<label class="form-label" for="default_route">Route par defaut (landing)</label>
<input class="form-input" type="text" id="default_route" name="default_route" maxlength="120" value="<?= $val('default_route') ?>">
<label class="form-label" for="default_route">Page d'accueil apres connexion</label>
<select class="form-input" id="default_route" name="default_route">
<option value=""> Aucune </option>
<?php foreach ($routeOptions as $path => $pageLabel): ?>
<option value="<?= htmlspecialchars($path, ENT_QUOTES, 'UTF-8') ?>"<?= $path === $currentRoute ? ' selected' : '' ?>><?= htmlspecialchars($pageLabel, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
<p class="form-helper">L'ecran affiche a cette personne quand elle se connecte.</p>
<?php if ($err('default_route') !== ''): ?><p class="form-error"><?= $err('default_route') ?></p><?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="order_source">Source de commande auto-taggee</label>
<label class="form-label" for="order_source">Canal de commande</label>
<select class="form-input" id="order_source" name="order_source">
<option value="">-- aucune (admin/manager) --</option>
<option value=""> Aucun (role de gestion) </option>
<?php foreach ($srcList as $src): ?>
<option value="<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>"<?= $src === $selectedSource ? ' selected' : '' ?>><?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?></option>
<option value="<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>"<?= $src === $selectedSource ? ' selected' : '' ?>><?= htmlspecialchars($canalLabel($src), ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
<p class="form-helper">Les commandes prises par ce role sont rattachees a ce canal.</p>
<?php if ($err('order_source') !== ''): ?><p class="form-error"><?= $err('order_source') ?></p><?php endif; ?>
</div>
<?php if ($id !== 0): ?>
<div class="form-group">
<label class="form-label"><input type="checkbox" name="is_active" value="1"<?= $active ? ' checked' : '' ?>> Role actif</label>
<label class="form-label"><input type="checkbox" name="is_active" value="1"<?= $active ? ' checked' : '' ?>> Ce role est actif</label>
</div>
<?php endif; ?>
<fieldset class="form-group">
<legend>Permissions</legend>
<legend>Droits d'acces</legend>
<p class="form-helper">Cochez ce que ce role est autorise a faire.</p>
<?php if ($err('permissions') !== ''): ?><p class="form-error"><?= $err('permissions') ?></p><?php endif; ?>
<div style="max-height:320px; overflow-y:auto;">
<?php foreach ($perms as $p): ?>
<?php
$pid = (int) ($p['id'] ?? 0);
$checked = in_array($pid, $selPerms, true);
?>
<label style="display:block; padding:2px 0;">
<input type="checkbox" name="perm_<?= $pid ?>" value="1"<?= $checked ? ' checked' : '' ?>>
<code><?= htmlspecialchars((string) ($p['code'] ?? ''), ENT_QUOTES, 'UTF-8') ?></code>
<span class="muted">- <?= htmlspecialchars((string) ($p['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
</label>
<div class="perm-grid">
<?php foreach ($groupOrder as $group): ?>
<?php if (empty($grouped[$group])): continue; endif; ?>
<div class="perm-group">
<h4 class="perm-group-title"><?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?></h4>
<?php foreach ($grouped[$group] as $item): ?>
<label class="perm-opt">
<input type="checkbox" name="perm_<?= $item['id'] ?>" value="1"<?= $item['checked'] ? ' checked' : '' ?>>
<?= htmlspecialchars($item['action'], ENT_QUOTES, 'UTF-8') ?>
</label>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</fieldset>
<fieldset class="form-group">
<legend>Sources de tableau de bord visibles</legend>
<legend>Canaux visibles sur le tableau de bord</legend>
<?php foreach ($srcList as $src): ?>
<label style="display:inline-block; margin-right:1rem;">
<label class="perm-opt">
<input type="checkbox" name="source_<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>" value="1"<?= in_array($src, $selSources, true) ? ' checked' : '' ?>>
<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>
<?= htmlspecialchars($canalLabel($src), ENT_QUOTES, 'UTF-8') ?>
</label>
<?php endforeach; ?>
</fieldset>
<fieldset class="form-group">
<legend>Re-autorisation (PIN equipier)</legend>
<legend>Confirmation par PIN</legend>
<div class="form-group">
<label class="form-label" for="pin_email">Email equipier</label>
<label class="form-label" for="pin_email">Email de l'equipier</label>
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off">
</div>
<div class="form-group">

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
/**
* Liste des roles (RBAC, role.manage), injectee dans admin/layout.php. Texte echappe.
* Presentation humanisee : page d'accueil et canal affiches en clair (la base garde
* les chemins / enums techniques).
*
* @var array<int, array<string, mixed>> $roles
*/
@ -11,11 +13,25 @@ declare(strict_types=1);
/** @var array<int, array<string, mixed>> $rows */
$rows = isset($roles) && is_array($roles) ? $roles : [];
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$routeLabels = [
'/admin/dashboard' => 'Tableau de bord',
'/admin/stats' => 'Statistiques',
'/admin/products' => 'Produits',
'/admin/menus' => 'Menus',
'/admin/ingredients' => 'Stock',
'/admin/categories' => 'Categories',
'/admin/users' => 'Comptes',
'/admin/roles' => 'Roles',
];
$canalLabels = ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive'];
$routeHuman = static fn (string $r): string => $r === '' ? '—' : ($routeLabels[$r] ?? $r);
$canalHuman = static fn (?string $s): string => ($s === null || $s === '') ? '—' : ($canalLabels[$s] ?? $s);
?>
<div class="page-header">
<div>
<h1 class="page-title">Roles et permissions</h1>
<p class="page-subtitle">Matrice RBAC. Modifier un role est une action sensible (PIN + audit).</p>
<h1 class="page-title">Roles et droits d'acces</h1>
<p class="page-subtitle">Modifier un role est une action sensible (confirmation par PIN).</p>
</div>
<div class="page-actions">
<a class="btn btn-primary" href="/admin/roles/new">Nouveau role</a>
@ -27,10 +43,10 @@ $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES,
<table>
<thead>
<tr>
<th>Code</th>
<th>Libelle</th>
<th>Route par defaut</th>
<th>Source</th>
<th>Nom</th>
<th>Code interne</th>
<th>Page d'accueil</th>
<th>Canal</th>
<th>Statut</th>
<th style="width:120px;"></th>
</tr>
@ -43,12 +59,13 @@ $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES,
<?php
$id = (int) ($row['id'] ?? 0);
$active = (int) ($row['is_active'] ?? 0) === 1;
$src = isset($row['order_source']) && is_string($row['order_source']) ? $row['order_source'] : null;
?>
<tr>
<td class="fw-600"><?= $esc($row['code'] ?? '') ?></td>
<td><?= $esc($row['label'] ?? '') ?></td>
<td class="muted"><?= $esc($row['default_route'] ?? '') ?></td>
<td class="muted"><?= $esc($row['order_source'] ?? '-') ?></td>
<td class="fw-600"><?= $esc($row['label'] ?? '') ?></td>
<td class="muted"><?= $esc($row['code'] ?? '') ?></td>
<td class="muted"><?= $esc($routeHuman((string) ($row['default_route'] ?? ''))) ?></td>
<td class="muted"><?= $esc($canalHuman($src)) ?></td>
<td>
<?php if ($active): ?>
<span class="pill pill-success">Actif</span>

View file

@ -1358,3 +1358,82 @@ 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; }
/* --- Matrice de droits d'acces groupee (formulaire Roles humanise) --- */
.perm-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-top: 10px;
}
.perm-group {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 14px 16px;
background: var(--color-surface);
}
.perm-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 8px;
color: var(--color-text);
}
.perm-opt {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--color-text-sec);
margin: 3px 14px 3px 0;
}
@media (max-width: 700px) {
.perm-grid { grid-template-columns: 1fr; }
}
/* Fieldsets de formulaire : pas de bordure native ; la legende = titre de section. */
.form-card fieldset {
border: none;
padding: 0;
margin: 0;
}
.form-card legend {
font-size: 15px;
font-weight: 700;
color: var(--color-text);
padding: 0;
margin-bottom: 4px;
}

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 = [
'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),
);
}

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