feat: shell back-office P3 (pages rendues serveur + garde) #14

Merged
Corentin merged 1 commit from feat/p3-admin-shell into dev 2026-06-15 21:25:07 +02:00
11 changed files with 619 additions and 3 deletions
Showing only changes of commit 65cb3008ee - Show all commits

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Core\DatabaseInterface;
/**
* Lecture des informations d'affichage d'un utilisateur (nom + libelle de role)
* pour l'entete du back-office. Separe d'Authorizer (qui ne traite que les
* permissions) ; depend de DatabaseInterface pour rester testable avec un double.
*/
final class UserDirectory
{
public function __construct(private readonly DatabaseInterface $db)
{
}
/**
* @return array{name: string, role_label: string}
*/
public function displayInfo(int $userId): array
{
$row = $this->db->fetch(
'SELECT u.first_name, u.last_name, r.label AS role_label '
. 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id',
['id' => $userId],
);
$first = is_string($row['first_name'] ?? null) ? $row['first_name'] : '';
$last = is_string($row['last_name'] ?? null) ? $row['last_name'] : '';
$name = trim($first . ' ' . $last);
return [
'name' => $name !== '' ? $name : 'Utilisateur',
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
];
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth\Csrf;
use App\Auth\GuardResult;
use App\Auth\UserDirectory;
use App\Core\Response;
/**
* Base des pages back-office rendues serveur (P3). Etend AuthenticatedController
* (session + autorisation) et :
* - rend dans le shell admin (topbar + sidebar) via layoutName(),
* - fournit guard() : applique RG-6/RG-T02 (redirige vers /login si non
* authentifie) puis RG-T03 (403 si la permission manque), sinon renvoie la
* GuardResult,
* - injecte le contexte commun du layout (utilisateur, role, permissions, CSRF).
*
* Non `final` : les controleurs concrets (Dashboard, Category...) en heritent ;
* les tests sous-classent pour injecter des doubles.
*/
abstract class AdminController extends AuthenticatedController
{
protected function layoutName(): string
{
return 'admin/layout';
}
/**
* Garde de page : 302 vers /login si la session est absente/expiree/inactive ;
* 403 (page admin) si $permission est exigee et non detenue ; sinon la
* GuardResult authentifiee. L'appelant fait : if ($g instanceof Response) return $g;
*/
protected function guard(?string $permission = null): GuardResult|Response
{
$result = $this->sessionGuard()->check();
if (!$result->authenticated || $result->userId === null || $result->roleId === null) {
return Response::make('', 302, ['Location' => '/login']);
}
if ($permission !== null && !$this->authorizer()->can($result->roleId, $permission)) {
return $this->adminView('admin/forbidden', ['title' => 'Acces refuse', 'activeNav' => ''], $result, 403);
}
return $result;
}
/**
* Rend une vue dans le shell admin en injectant le contexte commun
* (nom/role de l'utilisateur, permissions pour la navigation, jeton CSRF).
* Les cles passees dans $data ont priorite (ex. activeNav).
*
* @param array<string, mixed> $data
*/
protected function adminView(string $name, array $data, GuardResult $guard, int $status = 200): Response
{
$userId = $guard->userId ?? 0;
$roleId = $guard->roleId ?? 0;
$info = $this->userDirectory()->displayInfo($userId);
$context = [
'currentUserName' => $info['name'],
'currentUserRole' => $info['role_label'],
'permissions' => $this->authorizer()->permissionsFor($roleId),
'csrfToken' => Csrf::token($this->sessionManager()),
'activeNav' => '',
];
return $this->view($name, $data + $context, $status);
}
protected function userDirectory(): UserDirectory
{
return new UserDirectory($this->database);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Response;
/**
* Tableau de bord back-office. GET /admin/dashboard (landing par defaut du role
* admin, cf. seed role.default_route). Accessible a tout utilisateur authentifie ;
* les KPI reels (stats.read) seront ajoutes au chunk statistiques.
*
* Non `final` : les tests sous-classent pour injecter des doubles via les hooks.
*/
class DashboardController extends AdminController
{
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$guard = $this->guard();
if ($guard instanceof Response) {
return $guard;
}
return $this->adminView(
'admin/dashboard',
['title' => 'Tableau de bord - Wakdo Admin', 'activeNav' => 'dashboard'],
$guard,
);
}
}

View file

@ -7,8 +7,9 @@ namespace App\Core;
use RuntimeException;
/**
* Controleur de base. Toute la hierarchie de controleurs en herite
* (BaseController -> ProductController, etc., demonstration heritage Cr 4.c.1).
* Controleur de base. Toute la hierarchie de controleurs en herite (demonstration
* heritage Cr 4.c.1) : Controller -> AuthenticatedController -> AdminController ->
* DashboardController (et les futurs CRUD), ou directement HomeController / HealthController.
*
* Recoit ses dependances par constructeur : la requete courante, la config et
* l'acces BDD, injectes par le Router.
@ -41,11 +42,20 @@ abstract class Controller
protected function view(string $name, array $data = [], int $status = 200): Response
{
$content = $this->render($name, $data);
$html = $this->render('layout', $data + ['content' => $content]);
$html = $this->render($this->layoutName(), $data + ['content' => $content]);
return (new Response())->html($html, $status);
}
/**
* Gabarit enveloppant les vues. Defaut : le layout minimal. Les controleurs
* back-office surchargent ce hook pour rendre dans le shell admin.
*/
protected function layoutName(): string
{
return 'layout';
}
/**
* @param array<string, mixed> $data
*/

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* Fragment du tableau de bord, injecte dans admin/layout.php. Volontairement
* minimal en chunk shell : les KPI reels (ventes, commandes) viendront avec le
* chunk statistiques (permission stats.read).
*
* @var string $currentUserName
*/
$name = htmlspecialchars($currentUserName ?? 'Utilisateur', ENT_QUOTES, 'UTF-8');
?>
<div class="page-header">
<div>
<h1 class="page-title">Tableau de bord</h1>
<p class="page-subtitle">Bienvenue, <?= $name ?>.</p>
</div>
</div>
<section>
<p>Le back-office est en ligne. Utilisez la navigation pour gerer le catalogue,
les commandes et les utilisateurs selon vos permissions.</p>
<p><small>Les indicateurs (ventes, commandes du jour) seront ajoutes prochainement.</small></p>
</section>

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/**
* Fragment 403, injecte dans admin/layout.php : l'utilisateur est authentifie
* mais ne detient pas la permission requise (RG-T03).
*/
?>
<div class="page-header">
<div>
<h1 class="page-title">Acces refuse</h1>
<p class="page-subtitle">Vous n'avez pas la permission d'acceder a cette page.</p>
</div>
</div>
<section>
<p><a href="/admin/dashboard">Retour au tableau de bord</a></p>
</section>

View file

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
/**
* Shell du back-office (topbar + sidebar + zone de contenu), reutilise par toutes
* les pages admin rendues serveur. Recoit le contenu de la page et le contexte
* commun injecte par AdminController::adminView().
*
* Chemins d'assets ABSOLUS (/assets/...) : les pages sont servies sous des routes
* /admin/... alors que les fichiers vivent a la racine du docroot du vhost admin ;
* un chemin relatif resoudrait vers /admin/assets/... (404).
*
* @var string $title
* @var string $content
* @var string $currentUserName
* @var string $currentUserRole
* @var list<string> $permissions
* @var string $csrfToken
* @var string $activeNav
*/
$pageTitle = htmlspecialchars($title ?? 'Wakdo Admin', ENT_QUOTES, 'UTF-8');
$userName = htmlspecialchars($currentUserName ?? 'Utilisateur', ENT_QUOTES, 'UTF-8');
$userRole = htmlspecialchars($currentUserRole ?? '', ENT_QUOTES, 'UTF-8');
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$active = is_string($activeNav ?? null) ? $activeNav : '';
/** @var list<string> $perms */
$perms = isset($permissions) && is_array($permissions) ? $permissions : [];
$can = static fn (string $code): bool => in_array($code, $perms, true);
// Initiales pour l'avatar (2 lettres max), derivees du nom affiche. Fonctions
// multibyte (UTF-8) : un prenom a initiale accentuee (frequent en francais) doit
// produire une lettre valide, pas un octet de tete isole qui viderait l'echappement.
$initials = '';
foreach (preg_split('/\s+/', trim((string) ($currentUserName ?? ''))) ?: [] as $word) {
if ($word !== '' && mb_strlen($initials, 'UTF-8') < 2) {
$initials .= mb_strtoupper(mb_substr($word, 0, 1, 'UTF-8'), 'UTF-8');
}
}
$initials = $initials !== '' ? $initials : 'U';
/**
* @param string $code cle de nav active
* @param string $current
*/
$navClass = static function (string $code, string $current): string {
return $code === $current ? 'sidebar-item active' : 'sidebar-item';
};
?><!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title><?= $pageTitle ?></title>
<link rel="stylesheet" href="/assets/css/admin.css">
</head>
<body>
<div class="admin-layout">
<header class="topbar">
<div class="topbar-logo">
<img src="/assets/images/logo.png" alt="Wakdo">
<div>
<span class="topbar-logo-text">Wakdo</span>
<span class="topbar-logo-sub">Administration</span>
</div>
</div>
<div class="topbar-actions">
<div class="topbar-user">
<button class="topbar-user-btn" id="userMenuBtn" type="button" aria-haspopup="true" aria-expanded="false">
<div class="topbar-user-avatar"><?= htmlspecialchars($initials, ENT_QUOTES, 'UTF-8') ?></div>
<div>
<div class="topbar-user-name"><?= $userName ?></div>
<div class="topbar-user-role"><?= $userRole ?></div>
</div>
</button>
<div class="dropdown-menu" id="userMenu">
<form method="post" action="/logout">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button class="danger" type="submit">Se deconnecter</button>
</form>
</div>
</div>
</div>
</header>
<nav class="sidebar">
<div class="sidebar-section">
<div class="sidebar-section-label">Vue d'ensemble</div>
<a href="/admin/dashboard" class="<?= $navClass('dashboard', $active) ?>">Tableau de bord</a>
</div>
<?php if ($can('product.read') || $can('menu.read') || $can('category.manage')): ?>
<div class="sidebar-section">
<div class="sidebar-section-label">Catalogue</div>
<?php if ($can('category.manage')): ?>
<a href="/admin/categories" class="<?= $navClass('categories', $active) ?>">Categories</a>
<?php endif; ?>
<?php if ($can('product.read')): ?>
<a href="/admin/products" class="<?= $navClass('products', $active) ?>">Produits</a>
<?php endif; ?>
<?php if ($can('menu.read')): ?>
<a href="/admin/menus" class="<?= $navClass('menus', $active) ?>">Menus</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($can('order.read')): ?>
<div class="sidebar-section">
<div class="sidebar-section-label">Operations</div>
<a href="/admin/orders" class="<?= $navClass('orders', $active) ?>">Commandes</a>
</div>
<?php endif; ?>
<?php if ($can('user.read') || $can('role.manage')): ?>
<div class="sidebar-section">
<div class="sidebar-section-label">Administration</div>
<?php if ($can('user.read')): ?>
<a href="/admin/users" class="<?= $navClass('users', $active) ?>">Utilisateurs</a>
<?php endif; ?>
<?php if ($can('role.manage')): ?>
<a href="/admin/roles" class="<?= $navClass('roles', $active) ?>">Roles</a>
<?php endif; ?>
</div>
<?php endif; ?>
</nav>
<main class="content">
<?= $content ?? '' ?>
</main>
</div>
<script src="/assets/js/admin.js"></script>
</body>
</html>

View file

@ -12,6 +12,7 @@ declare(strict_types=1);
use App\Auth\SessionManager;
use App\Controllers\AuthController;
use App\Controllers\DashboardController;
use App\Controllers\HealthController;
use App\Controllers\HomeController;
use App\Controllers\MeController;
@ -61,6 +62,9 @@ try {
// RBAC : identite + permissions de la session courante (gardee par SessionGuard).
$router->add('GET', '/api/me', [MeController::class, 'show']);
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
$router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']);
$response = $router->dispatch(Request::fromGlobals());
$response->send();
} catch (Throwable $exception) {

View file

@ -90,6 +90,13 @@ final class FakeDatabase implements DatabaseInterface
*/
public ?array $pinUserRow = null;
/**
* Ligne renvoyee pour UserDirectory::displayInfo (nom + libelle role) ; null = absent.
*
* @var array<string, mixed>|null
*/
public ?array $userDisplayRow = null;
/** Si non nul, execute() leve cette exception (simulation panne DB -> fail-closed). */
public ?RuntimeException $failOnExecute = null;
@ -103,6 +110,12 @@ final class FakeDatabase implements DatabaseInterface
{
$this->reads[] = ['sql' => $sql, 'params' => $params];
// Doit passer AVANT le lookup auth : la requete displayInfo contient aussi
// 'FROM user u JOIN role' mais selectionne 'AS role_label'.
if (str_contains($sql, 'AS role_label')) {
return $this->userDisplayRow;
}
if (str_contains($sql, 'FROM user u JOIN role')) {
return $this->userRow;
}

View file

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use App\Auth\Authorizer;
use App\Auth\GuardResult;
use App\Auth\SessionGuard;
use App\Auth\SessionManager;
use App\Auth\UserDirectory;
use App\Controllers\DashboardController;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
use App\Tests\Support\FakeDatabase;
/**
* Sous-classe de test : injecte session test + FakeDatabase dans la garde,
* l'autorisation et l'annuaire, sans base reelle.
*/
final class TestDashboardController extends DashboardController
{
public function __construct(
Request $request,
Config $config,
Database $database,
private readonly SessionManager $testSession,
private readonly FakeDatabase $fakeDb,
) {
parent::__construct($request, $config, $database);
}
protected function sessionManager(): SessionManager
{
return $this->testSession;
}
protected function sessionGuard(): SessionGuard
{
return new SessionGuard($this->testSession, $this->fakeDb, $this->config);
}
protected function authorizer(): Authorizer
{
return new Authorizer($this->fakeDb);
}
protected function userDirectory(): UserDirectory
{
return new UserDirectory($this->fakeDb);
}
/**
* Expose le chemin garde par permission d'AdminController::guard() (RG-T03),
* que le dashboard (auth seule) n'exerce pas.
*/
public function gated(): GuardResult|Response
{
$guard = $this->guard('user.read');
if ($guard instanceof Response) {
return $guard;
}
return $this->adminView('admin/dashboard', ['title' => 't', 'activeNav' => ''], $guard);
}
}
final class DashboardControllerTest extends TestCase
{
/** @var list<string> */
private array $touchedKeys = [];
protected function setUp(): void
{
$this->setEnv('SESSION_LIFETIME_IDLE', '14400');
$this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000');
}
protected function tearDown(): void
{
foreach ($this->touchedKeys as $key) {
putenv($key);
}
$this->touchedKeys = [];
}
private function setEnv(string $key, string $value): void
{
$this->touchedKeys[] = $key;
putenv($key . '=' . $value);
}
private function controller(SessionManager $session, FakeDatabase $db): TestDashboardController
{
$request = new Request('GET', '/admin/dashboard', [], [], '', '203.0.113.5');
return new TestDashboardController($request, new Config(), new Database(new Config()), $session, $db);
}
private function authedSession(): SessionManager
{
$session = new SessionManager(new Config(), true);
$now = time();
$session->set('user_id', 1);
$session->set('role_id', 1);
$session->set('logged_in_at', $now - 100);
$session->set('last_activity', $now - 50);
return $session;
}
public function testRedirectsToLoginWithoutSession(): void
{
$response = $this->controller(new SessionManager(new Config(), true), new FakeDatabase())->index();
self::assertSame(302, $response->status());
self::assertSame('/login', $response->header('Location'));
}
public function testInactiveUserRedirectsToLogin(): void
{
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 0];
$response = $this->controller($this->authedSession(), $db)->index();
self::assertSame(302, $response->status());
self::assertSame('/login', $response->header('Location'));
}
public function testRendersShellWhenAuthenticated(): void
{
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 1];
$db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur'];
$db->permissionCodes = ['product.read', 'user.read'];
$response = $this->controller($this->authedSession(), $db)->index();
self::assertSame(200, $response->status());
$body = $response->body();
// Shell rendu (topbar/sidebar) + identite + page.
self::assertStringContainsString('admin-layout', $body);
self::assertStringContainsString('Tableau de bord', $body);
self::assertStringContainsString('Corentin J', $body);
self::assertStringContainsString('Administrateur', $body);
// Marqueur present UNIQUEMENT dans le fragment dashboard (absent du layout) :
// verifie que le contenu est bien compose DANS le shell (pas un $content vide).
self::assertStringContainsString('Bienvenue, Corentin J', $body);
// Navigation conditionnee aux permissions.
self::assertStringContainsString('/admin/products', $body); // product.read present
self::assertStringContainsString('/admin/users', $body); // user.read present
self::assertStringNotContainsString('/admin/roles', $body); // role.manage absent
// Deconnexion = formulaire POST avec CSRF.
self::assertStringContainsString('action="/logout"', $body);
self::assertStringContainsString('name="_csrf"', $body);
}
public function testForbiddenWhenPermissionDenied(): void
{
// Authentifie mais sans la permission requise (RG-T03) -> 403 + page forbidden.
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 1];
$db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Equipier'];
$db->canResult = false;
$response = $this->controller($this->authedSession(), $db)->gated();
self::assertSame(403, $response->status());
self::assertStringContainsString('Acces refuse', $response->body());
}
public function testGatedPageRendersWhenPermitted(): void
{
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 1];
$db->userDisplayRow = ['first_name' => 'Corentin', 'last_name' => 'J', 'role_label' => 'Administrateur'];
$db->canResult = true;
$db->permissionCodes = ['user.read'];
$response = $this->controller($this->authedSession(), $db)->gated();
self::assertSame(200, $response->status());
}
public function testEscapesUserIdentity(): void
{
// Donnees user-editables (nom/role) : doivent etre echappees (RG-T15).
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 1];
$db->userDisplayRow = [
'first_name' => '<script>alert(1)</script>',
'last_name' => 'x',
'role_label' => 'Admin <b>& co</b>',
];
$db->permissionCodes = ['user.read'];
$body = $this->controller($this->authedSession(), $db)->index()->body();
self::assertStringContainsString('&lt;script&gt;', $body);
self::assertStringNotContainsString('<script>alert(1)</script>', $body);
self::assertStringContainsString('&amp; co', $body);
self::assertStringNotContainsString('Admin <b>', $body);
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\UserDirectory;
use App\Tests\Support\FakeDatabase;
/**
* Lecture des infos d'affichage (nom + libelle de role) pour l'entete admin.
*/
final class UserDirectoryTest extends TestCase
{
private FakeDatabase $db;
protected function setUp(): void
{
$this->db = new FakeDatabase();
}
public function testDisplayInfoReturnsNameAndRoleLabel(): void
{
$this->db->userDisplayRow = [
'first_name' => 'Corentin',
'last_name' => 'J',
'role_label' => 'Administrateur',
];
self::assertSame(
['name' => 'Corentin J', 'role_label' => 'Administrateur'],
(new UserDirectory($this->db))->displayInfo(7),
);
}
public function testDisplayInfoDefaultsWhenAbsent(): void
{
$this->db->userDisplayRow = null;
self::assertSame(
['name' => 'Utilisateur', 'role_label' => ''],
(new UserDirectory($this->db))->displayInfo(999),
);
}
}