Compare commits
2 commits
2e1d2e3126
...
67fe086581
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67fe086581 | ||
|
|
b6dfc2a56c |
9 changed files with 457 additions and 53 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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