Refatoring : Working state
This commit is contained in:
@@ -50,9 +50,9 @@ use App\Shared\Http\SessionManager;
|
|||||||
use App\Shared\Http\SessionManagerInterface;
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
use App\Shared\Mail\MailService;
|
use App\Shared\Mail\MailService;
|
||||||
use App\Shared\Mail\MailServiceInterface;
|
use App\Shared\Mail\MailServiceInterface;
|
||||||
use App\User\UserRepository;
|
use App\User\Application\UserApplicationService;
|
||||||
|
use App\User\Infrastructure\PdoUserRepository;
|
||||||
use App\User\UserRepositoryInterface;
|
use App\User\UserRepositoryInterface;
|
||||||
use App\User\UserService;
|
|
||||||
use App\User\UserServiceInterface;
|
use App\User\UserServiceInterface;
|
||||||
|
|
||||||
use Monolog\Handler\StreamHandler;
|
use Monolog\Handler\StreamHandler;
|
||||||
@@ -70,12 +70,12 @@ return [
|
|||||||
|
|
||||||
AuthServiceInterface::class => autowire(AuthService::class),
|
AuthServiceInterface::class => autowire(AuthService::class),
|
||||||
PostServiceInterface::class => autowire(PostApplicationService::class),
|
PostServiceInterface::class => autowire(PostApplicationService::class),
|
||||||
UserServiceInterface::class => autowire(UserService::class),
|
UserServiceInterface::class => autowire(UserApplicationService::class),
|
||||||
CategoryServiceInterface::class => autowire(CategoryApplicationService::class),
|
CategoryServiceInterface::class => autowire(CategoryApplicationService::class),
|
||||||
CategoryRepositoryInterface::class => autowire(PdoCategoryRepository::class),
|
CategoryRepositoryInterface::class => autowire(PdoCategoryRepository::class),
|
||||||
MediaRepositoryInterface::class => autowire(MediaRepository::class),
|
MediaRepositoryInterface::class => autowire(MediaRepository::class),
|
||||||
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
||||||
UserRepositoryInterface::class => autowire(UserRepository::class),
|
UserRepositoryInterface::class => autowire(PdoUserRepository::class),
|
||||||
LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class),
|
LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class),
|
||||||
PasswordResetRepositoryInterface::class => autowire(PasswordResetRepository::class),
|
PasswordResetRepositoryInterface::class => autowire(PasswordResetRepository::class),
|
||||||
PasswordResetServiceInterface::class => autowire(PasswordResetService::class),
|
PasswordResetServiceInterface::class => autowire(PasswordResetService::class),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
> **Refactor DDD légère — lot 1**
|
> **Refactor DDD légère — lots 1 et 2**
|
||||||
>
|
>
|
||||||
> `Post/` et `Category/` introduisent maintenant une organisation verticale
|
> `Post/`, `Category/` et `User/` introduisent maintenant une organisation verticale
|
||||||
> `Application / Infrastructure / Http / Domain` pour alléger la lecture et préparer
|
> `Application / Infrastructure / Http / Domain` pour alléger la lecture et préparer
|
||||||
> un découpage plus fin par cas d'usage. Les classes historiques à la racine du domaine
|
> un découpage plus fin par cas d'usage. Les classes historiques à la racine du domaine
|
||||||
> sont conservées comme **ponts de compatibilité** afin de préserver les routes, le conteneur
|
> sont conservées comme **ponts de compatibilité** afin de préserver les routes, le conteneur
|
||||||
@@ -83,8 +83,8 @@ final class PostService
|
|||||||
|
|
||||||
| Interface | Implémentation | Domaine |
|
| Interface | Implémentation | Domaine |
|
||||||
|------------------------------------|---------------------------|------------|
|
|------------------------------------|---------------------------|------------|
|
||||||
| `UserRepositoryInterface` | `UserRepository` | `User/` |
|
| `UserRepositoryInterface` | `PdoUserRepository` | `User/` |
|
||||||
| `UserServiceInterface` | `UserService` | `User/` |
|
| `UserServiceInterface` | `UserApplicationService` | `User/` |
|
||||||
| `LoginAttemptRepositoryInterface` | `LoginAttemptRepository` | `Auth/` |
|
| `LoginAttemptRepositoryInterface` | `LoginAttemptRepository` | `Auth/` |
|
||||||
| `PasswordResetRepositoryInterface` | `PasswordResetRepository` | `Auth/` |
|
| `PasswordResetRepositoryInterface` | `PasswordResetRepository` | `Auth/` |
|
||||||
| `PasswordResetServiceInterface` | `PasswordResetService` | `Auth/` |
|
| `PasswordResetServiceInterface` | `PasswordResetService` | `Auth/` |
|
||||||
|
|||||||
104
src/User/Application/UserApplicationService.php
Normal file
104
src/User/Application/UserApplicationService.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\User\Application;
|
||||||
|
|
||||||
|
use App\Shared\Exception\NotFoundException;
|
||||||
|
use App\Shared\Pagination\PaginatedResult;
|
||||||
|
use App\User\Domain\RolePolicy;
|
||||||
|
use App\User\Exception\DuplicateEmailException;
|
||||||
|
use App\User\Exception\DuplicateUsernameException;
|
||||||
|
use App\User\Exception\WeakPasswordException;
|
||||||
|
use App\User\User;
|
||||||
|
use App\User\UserRepositoryInterface;
|
||||||
|
use App\User\UserServiceInterface;
|
||||||
|
|
||||||
|
class UserApplicationService implements UserServiceInterface
|
||||||
|
{
|
||||||
|
private readonly RolePolicy $rolePolicy;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserRepositoryInterface $userRepository,
|
||||||
|
?RolePolicy $rolePolicy = null,
|
||||||
|
) {
|
||||||
|
$this->rolePolicy = $rolePolicy ?? new RolePolicy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return User[] */
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
return $this->userRepository->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return PaginatedResult<User> */
|
||||||
|
public function findPaginated(int $page, int $perPage): PaginatedResult
|
||||||
|
{
|
||||||
|
$page = max(1, $page);
|
||||||
|
$total = $this->userRepository->countAll();
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
return new PaginatedResult(
|
||||||
|
$this->userRepository->findPage($perPage, $offset),
|
||||||
|
$total,
|
||||||
|
$page,
|
||||||
|
$perPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?User
|
||||||
|
{
|
||||||
|
return $this->userRepository->findById($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
$this->requireExistingUser($id);
|
||||||
|
$this->userRepository->delete($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateRole(int $id, string $role): void
|
||||||
|
{
|
||||||
|
$this->rolePolicy->assertAssignable($role);
|
||||||
|
$this->requireExistingUser($id);
|
||||||
|
$this->userRepository->updateRole($id, $role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createUser(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User
|
||||||
|
{
|
||||||
|
$username = mb_strtolower(trim($username));
|
||||||
|
$email = mb_strtolower(trim($email));
|
||||||
|
$plainPassword = trim($plainPassword);
|
||||||
|
|
||||||
|
$this->rolePolicy->assertAssignable($role);
|
||||||
|
|
||||||
|
if ($this->userRepository->findByUsername($username)) {
|
||||||
|
throw new DuplicateUsernameException($username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->userRepository->findByEmail($email)) {
|
||||||
|
throw new DuplicateEmailException($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($plainPassword) < 8) {
|
||||||
|
throw new WeakPasswordException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||||
|
$user = new User(0, $username, $email, $passwordHash, $role);
|
||||||
|
|
||||||
|
$this->userRepository->create($user);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireExistingUser(int $id): User
|
||||||
|
{
|
||||||
|
$user = $this->userRepository->findById($id);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
throw new NotFoundException('Utilisateur', $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/User/Domain/RolePolicy.php
Normal file
42
src/User/Domain/RolePolicy.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\User\Domain;
|
||||||
|
|
||||||
|
use App\User\Exception\InvalidRoleException;
|
||||||
|
use App\User\Exception\RoleAssignmentNotAllowedException;
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
|
class RolePolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function allRoles(): array
|
||||||
|
{
|
||||||
|
return User::allRoles();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function assignableRoles(): array
|
||||||
|
{
|
||||||
|
return User::assignableRoles();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws InvalidRoleException
|
||||||
|
* @throws RoleAssignmentNotAllowedException
|
||||||
|
*/
|
||||||
|
public function assertAssignable(string $role): void
|
||||||
|
{
|
||||||
|
if (!in_array($role, $this->allRoles(), true)) {
|
||||||
|
throw new InvalidRoleException($role);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($role, $this->assignableRoles(), true)) {
|
||||||
|
throw new RoleAssignmentNotAllowedException($role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/User/Http/UserController.php
Normal file
168
src/User/Http/UserController.php
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\User\Http;
|
||||||
|
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
|
use App\Shared\Pagination\PaginationPresenter;
|
||||||
|
use App\User\Domain\RolePolicy;
|
||||||
|
use App\User\Exception\DuplicateEmailException;
|
||||||
|
use App\User\Exception\DuplicateUsernameException;
|
||||||
|
use App\User\Exception\InvalidRoleException;
|
||||||
|
use App\User\Exception\RoleAssignmentNotAllowedException;
|
||||||
|
use App\User\Exception\WeakPasswordException;
|
||||||
|
use App\User\UserServiceInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class UserController
|
||||||
|
{
|
||||||
|
private const PER_PAGE = 15;
|
||||||
|
|
||||||
|
private readonly RolePolicy $rolePolicy;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Twig $view,
|
||||||
|
private readonly UserServiceInterface $userService,
|
||||||
|
private readonly FlashServiceInterface $flash,
|
||||||
|
private readonly SessionManagerInterface $sessionManager,
|
||||||
|
?RolePolicy $rolePolicy = null,
|
||||||
|
) {
|
||||||
|
$this->rolePolicy = $rolePolicy ?? new RolePolicy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$page = PaginationPresenter::resolvePage($req->getQueryParams());
|
||||||
|
$paginated = $this->userService->findPaginated($page, self::PER_PAGE);
|
||||||
|
|
||||||
|
return $this->view->render($res, 'admin/users/index.twig', [
|
||||||
|
'users' => $paginated->getItems(),
|
||||||
|
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||||
|
'currentUserId' => $this->sessionManager->getUserId(),
|
||||||
|
'assignableRoles' => $this->rolePolicy->assignableRoles(),
|
||||||
|
'error' => $this->flash->get('user_error'),
|
||||||
|
'success' => $this->flash->get('user_success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showCreate(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
return $this->view->render($res, 'admin/users/form.twig', [
|
||||||
|
'assignableRoles' => $this->rolePolicy->assignableRoles(),
|
||||||
|
'error' => $this->flash->get('user_error'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$username = trim((string) ($data['username'] ?? ''));
|
||||||
|
$email = trim((string) ($data['email'] ?? ''));
|
||||||
|
$password = trim((string) ($data['password'] ?? ''));
|
||||||
|
$confirm = trim((string) ($data['password_confirm'] ?? ''));
|
||||||
|
$rawRole = trim((string) ($data['role'] ?? ''));
|
||||||
|
$role = in_array($rawRole, $this->rolePolicy->assignableRoles(), true) ? $rawRole : 'user';
|
||||||
|
|
||||||
|
if ($password !== $confirm) {
|
||||||
|
$this->flash->set('user_error', 'Les mots de passe ne correspondent pas');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->userService->createUser($username, $email, $password, $role);
|
||||||
|
$this->flash->set('user_success', "L'utilisateur « {$username} » a été créé avec succès");
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||||
|
} catch (DuplicateUsernameException) {
|
||||||
|
$this->flash->set('user_error', "Ce nom d'utilisateur est déjà pris");
|
||||||
|
} catch (DuplicateEmailException) {
|
||||||
|
$this->flash->set('user_error', 'Cette adresse e-mail est déjà utilisée');
|
||||||
|
} catch (WeakPasswordException) {
|
||||||
|
$this->flash->set('user_error', 'Le mot de passe doit contenir au moins 8 caractères');
|
||||||
|
} catch (InvalidRoleException | RoleAssignmentNotAllowedException $e) {
|
||||||
|
$this->flash->set('user_error', $e->getMessage());
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->flash->set('user_error', "Une erreur inattendue s'est produite");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $args */
|
||||||
|
public function updateRole(Request $req, Response $res, array $args): Response
|
||||||
|
{
|
||||||
|
$id = (int) $args['id'];
|
||||||
|
$user = $this->userService->findById($id);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
$this->flash->set('user_error', 'Utilisateur introuvable');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id === $this->sessionManager->getUserId()) {
|
||||||
|
$this->flash->set('user_error', 'Vous ne pouvez pas modifier votre propre rôle');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->isAdmin()) {
|
||||||
|
$this->flash->set('user_error', 'Le rôle d\'un administrateur ne peut pas être modifié depuis l\'interface');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = (array) $req->getParsedBody();
|
||||||
|
$rawRole = trim((string) ($body['role'] ?? ''));
|
||||||
|
$role = in_array($rawRole, $this->rolePolicy->assignableRoles(), true) ? $rawRole : null;
|
||||||
|
|
||||||
|
if ($role === null) {
|
||||||
|
$this->flash->set('user_error', 'Rôle invalide');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->userService->updateRole($id, $role);
|
||||||
|
$this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour");
|
||||||
|
} catch (InvalidRoleException | RoleAssignmentNotAllowedException $e) {
|
||||||
|
$this->flash->set('user_error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $args */
|
||||||
|
public function delete(Request $req, Response $res, array $args): Response
|
||||||
|
{
|
||||||
|
$id = (int) $args['id'];
|
||||||
|
$user = $this->userService->findById($id);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
$this->flash->set('user_error', 'Utilisateur introuvable');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->isAdmin()) {
|
||||||
|
$this->flash->set('user_error', 'Le compte administrateur ne peut pas être supprimé');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id === $this->sessionManager->getUserId()) {
|
||||||
|
$this->flash->set('user_error', 'Vous ne pouvez pas supprimer votre propre compte');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->userService->delete($id);
|
||||||
|
$this->flash->set('user_success', "L'utilisateur « {$user->getUsername()} » a été supprimé avec succès");
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/User/Infrastructure/PdoUserRepository.php
Normal file
110
src/User/Infrastructure/PdoUserRepository.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\User\Infrastructure;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
|
use App\User\UserRepositoryInterface;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class PdoUserRepository implements UserRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PDO $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return User[] */
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->query('SELECT * FROM users ORDER BY created_at ASC');
|
||||||
|
if ($stmt === false) {
|
||||||
|
throw new \RuntimeException('La requête SELECT sur users a échoué.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return User[] */
|
||||||
|
public function findPage(int $limit, int $offset): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('SELECT * FROM users ORDER BY created_at ASC LIMIT :limit OFFSET :offset');
|
||||||
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countAll(): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->query('SELECT COUNT(*) FROM users');
|
||||||
|
if ($stmt === false) {
|
||||||
|
throw new \RuntimeException('La requête COUNT sur users a échoué.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($stmt->fetchColumn() ?: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?User
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ? User::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByUsername(string $username): ?User
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('SELECT * FROM users WHERE username = :username');
|
||||||
|
$stmt->execute([':username' => $username]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ? User::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByEmail(string $email): ?User
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
|
||||||
|
$stmt->execute([':email' => $email]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ? User::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'INSERT INTO users (username, email, password_hash, role, created_at)
|
||||||
|
VALUES (:username, :email, :password_hash, :role, :created_at)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':username' => $user->getUsername(),
|
||||||
|
':email' => $user->getEmail(),
|
||||||
|
':password_hash' => $user->getPasswordHash(),
|
||||||
|
':role' => $user->getRole(),
|
||||||
|
':created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $this->db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePassword(int $id, string $newHash): void
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :id');
|
||||||
|
$stmt->execute([':password_hash' => $newHash, ':id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateRole(int $id, string $role): void
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('UPDATE users SET role = :role WHERE id = :id');
|
||||||
|
$stmt->execute([':role' => $role, ':id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('DELETE FROM users WHERE id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,155 +3,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\User;
|
namespace App\User;
|
||||||
|
|
||||||
use App\Shared\Http\FlashServiceInterface;
|
/**
|
||||||
use App\Shared\Http\SessionManagerInterface;
|
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
|
||||||
use App\Shared\Pagination\PaginationPresenter;
|
* App\User\Http\UserController.
|
||||||
use App\User\Exception\DuplicateEmailException;
|
*/
|
||||||
use App\User\Exception\DuplicateUsernameException;
|
final class UserController extends Http\UserController
|
||||||
use App\User\Exception\InvalidRoleException;
|
|
||||||
use App\User\Exception\RoleAssignmentNotAllowedException;
|
|
||||||
use App\User\Exception\WeakPasswordException;
|
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
|
||||||
use Slim\Views\Twig;
|
|
||||||
|
|
||||||
final class UserController
|
|
||||||
{
|
{
|
||||||
private const PER_PAGE = 15;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly Twig $view,
|
|
||||||
private readonly UserServiceInterface $userService,
|
|
||||||
private readonly FlashServiceInterface $flash,
|
|
||||||
private readonly SessionManagerInterface $sessionManager,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index(Request $req, Response $res): Response
|
|
||||||
{
|
|
||||||
$page = PaginationPresenter::resolvePage($req->getQueryParams());
|
|
||||||
$paginated = $this->userService->findPaginated($page, self::PER_PAGE);
|
|
||||||
|
|
||||||
return $this->view->render($res, 'admin/users/index.twig', [
|
|
||||||
'users' => $paginated->getItems(),
|
|
||||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
|
||||||
'currentUserId' => $this->sessionManager->getUserId(),
|
|
||||||
'assignableRoles' => User::assignableRoles(),
|
|
||||||
'error' => $this->flash->get('user_error'),
|
|
||||||
'success' => $this->flash->get('user_success'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function showCreate(Request $req, Response $res): Response
|
|
||||||
{
|
|
||||||
return $this->view->render($res, 'admin/users/form.twig', [
|
|
||||||
'assignableRoles' => User::assignableRoles(),
|
|
||||||
'error' => $this->flash->get('user_error'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(Request $req, Response $res): Response
|
|
||||||
{
|
|
||||||
$data = (array) $req->getParsedBody();
|
|
||||||
$username = trim((string) ($data['username'] ?? ''));
|
|
||||||
$email = trim((string) ($data['email'] ?? ''));
|
|
||||||
$password = trim((string) ($data['password'] ?? ''));
|
|
||||||
$confirm = trim((string) ($data['password_confirm'] ?? ''));
|
|
||||||
$rawRole = trim((string) ($data['role'] ?? ''));
|
|
||||||
$role = in_array($rawRole, User::assignableRoles(), true) ? $rawRole : User::ROLE_USER;
|
|
||||||
|
|
||||||
if ($password !== $confirm) {
|
|
||||||
$this->flash->set('user_error', 'Les mots de passe ne correspondent pas');
|
|
||||||
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->userService->createUser($username, $email, $password, $role);
|
|
||||||
$this->flash->set('user_success', "L'utilisateur « {$username} » a été créé avec succès");
|
|
||||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
|
||||||
} catch (DuplicateUsernameException) {
|
|
||||||
$this->flash->set('user_error', "Ce nom d'utilisateur est déjà pris");
|
|
||||||
} catch (DuplicateEmailException) {
|
|
||||||
$this->flash->set('user_error', 'Cette adresse e-mail est déjà utilisée');
|
|
||||||
} catch (WeakPasswordException) {
|
|
||||||
$this->flash->set('user_error', 'Le mot de passe doit contenir au moins 8 caractères');
|
|
||||||
} catch (InvalidRoleException|RoleAssignmentNotAllowedException $e) {
|
|
||||||
$this->flash->set('user_error', $e->getMessage());
|
|
||||||
} catch (\Throwable) {
|
|
||||||
$this->flash->set('user_error', "Une erreur inattendue s'est produite");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $args
|
|
||||||
*/
|
|
||||||
public function updateRole(Request $req, Response $res, array $args): Response
|
|
||||||
{
|
|
||||||
$id = (int) $args['id'];
|
|
||||||
$user = $this->userService->findById($id);
|
|
||||||
|
|
||||||
if ($user === null) {
|
|
||||||
$this->flash->set('user_error', 'Utilisateur introuvable');
|
|
||||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($id === $this->sessionManager->getUserId()) {
|
|
||||||
$this->flash->set('user_error', 'Vous ne pouvez pas modifier votre propre rôle');
|
|
||||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->isAdmin()) {
|
|
||||||
$this->flash->set('user_error', 'Le rôle d\'un administrateur ne peut pas être modifié depuis l\'interface');
|
|
||||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = (array) $req->getParsedBody();
|
|
||||||
$rawRole = trim((string) ($body['role'] ?? ''));
|
|
||||||
$role = in_array($rawRole, User::assignableRoles(), true) ? $rawRole : null;
|
|
||||||
|
|
||||||
if ($role === null) {
|
|
||||||
$this->flash->set('user_error', 'Rôle invalide');
|
|
||||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->userService->updateRole($id, $role);
|
|
||||||
$this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour");
|
|
||||||
} catch (InvalidRoleException|RoleAssignmentNotAllowedException $e) {
|
|
||||||
$this->flash->set('user_error', $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $args
|
|
||||||
*/
|
|
||||||
public function delete(Request $req, Response $res, array $args): Response
|
|
||||||
{
|
|
||||||
$id = (int) $args['id'];
|
|
||||||
$user = $this->userService->findById($id);
|
|
||||||
|
|
||||||
if ($user === null) {
|
|
||||||
$this->flash->set('user_error', 'Utilisateur introuvable');
|
|
||||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->isAdmin()) {
|
|
||||||
$this->flash->set('user_error', 'Le compte administrateur ne peut pas être supprimé');
|
|
||||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($id === $this->sessionManager->getUserId()) {
|
|
||||||
$this->flash->set('user_error', 'Vous ne pouvez pas supprimer votre propre compte');
|
|
||||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->userService->delete($id);
|
|
||||||
$this->flash->set('user_success', "L'utilisateur « {$user->getUsername()} » a été supprimé avec succès");
|
|
||||||
|
|
||||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,104 +3,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\User;
|
namespace App\User;
|
||||||
|
|
||||||
use PDO;
|
use App\User\Infrastructure\PdoUserRepository;
|
||||||
|
|
||||||
final class UserRepository implements UserRepositoryInterface
|
/**
|
||||||
|
* Pont de compatibilité : l'implémentation PDO principale vit désormais dans
|
||||||
|
* App\User\Infrastructure\PdoUserRepository.
|
||||||
|
*/
|
||||||
|
final class UserRepository extends PdoUserRepository implements UserRepositoryInterface
|
||||||
{
|
{
|
||||||
public function __construct(private readonly PDO $db)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findAll(): array
|
|
||||||
{
|
|
||||||
$stmt = $this->db->query('SELECT * FROM users ORDER BY created_at ASC');
|
|
||||||
if ($stmt === false) {
|
|
||||||
throw new \RuntimeException('La requête SELECT sur users a échoué.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findPage(int $limit, int $offset): array
|
|
||||||
{
|
|
||||||
$stmt = $this->db->prepare('SELECT * FROM users ORDER BY created_at ASC LIMIT :limit OFFSET :offset');
|
|
||||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
|
||||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
|
||||||
$stmt->execute();
|
|
||||||
|
|
||||||
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function countAll(): int
|
|
||||||
{
|
|
||||||
$stmt = $this->db->query('SELECT COUNT(*) FROM users');
|
|
||||||
if ($stmt === false) {
|
|
||||||
throw new \RuntimeException('La requête COUNT sur users a échoué.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) ($stmt->fetchColumn() ?: 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findById(int $id): ?User
|
|
||||||
{
|
|
||||||
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
|
|
||||||
$stmt->execute([':id' => $id]);
|
|
||||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
return $row ? User::fromArray($row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByUsername(string $username): ?User
|
|
||||||
{
|
|
||||||
$stmt = $this->db->prepare('SELECT * FROM users WHERE username = :username');
|
|
||||||
$stmt->execute([':username' => $username]);
|
|
||||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
return $row ? User::fromArray($row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByEmail(string $email): ?User
|
|
||||||
{
|
|
||||||
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
|
|
||||||
$stmt->execute([':email' => $email]);
|
|
||||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
return $row ? User::fromArray($row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): int
|
|
||||||
{
|
|
||||||
$stmt = $this->db->prepare(
|
|
||||||
'INSERT INTO users (username, email, password_hash, role, created_at)
|
|
||||||
VALUES (:username, :email, :password_hash, :role, :created_at)'
|
|
||||||
);
|
|
||||||
|
|
||||||
$stmt->execute([
|
|
||||||
':username' => $user->getUsername(),
|
|
||||||
':email' => $user->getEmail(),
|
|
||||||
':password_hash' => $user->getPasswordHash(),
|
|
||||||
':role' => $user->getRole(),
|
|
||||||
':created_at' => date('Y-m-d H:i:s'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (int) $this->db->lastInsertId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatePassword(int $id, string $newHash): void
|
|
||||||
{
|
|
||||||
$stmt = $this->db->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :id');
|
|
||||||
$stmt->execute([':password_hash' => $newHash, ':id' => $id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateRole(int $id, string $role): void
|
|
||||||
{
|
|
||||||
$stmt = $this->db->prepare('UPDATE users SET role = :role WHERE id = :id');
|
|
||||||
$stmt->execute([':role' => $role, ':id' => $id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $this->db->prepare('DELETE FROM users WHERE id = :id');
|
|
||||||
$stmt->execute([':id' => $id]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,108 +3,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\User;
|
namespace App\User;
|
||||||
|
|
||||||
use App\Shared\Exception\NotFoundException;
|
use App\User\Application\UserApplicationService;
|
||||||
use App\Shared\Pagination\PaginatedResult;
|
|
||||||
use App\User\Exception\DuplicateEmailException;
|
|
||||||
use App\User\Exception\DuplicateUsernameException;
|
|
||||||
use App\User\Exception\InvalidRoleException;
|
|
||||||
use App\User\Exception\RoleAssignmentNotAllowedException;
|
|
||||||
use App\User\Exception\WeakPasswordException;
|
|
||||||
|
|
||||||
final class UserService implements UserServiceInterface
|
/**
|
||||||
{
|
* Pont de compatibilité : l'implémentation métier principale vit désormais dans
|
||||||
public function __construct(
|
* App\User\Application\UserApplicationService.
|
||||||
private readonly UserRepositoryInterface $userRepository,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findAll(): array
|
|
||||||
{
|
|
||||||
return $this->userRepository->findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return PaginatedResult<User>
|
|
||||||
*/
|
*/
|
||||||
public function findPaginated(int $page, int $perPage): PaginatedResult
|
final class UserService extends UserApplicationService implements UserServiceInterface
|
||||||
{
|
{
|
||||||
$page = max(1, $page);
|
|
||||||
$total = $this->userRepository->countAll();
|
|
||||||
$offset = ($page - 1) * $perPage;
|
|
||||||
|
|
||||||
return new PaginatedResult(
|
|
||||||
$this->userRepository->findPage($perPage, $offset),
|
|
||||||
$total,
|
|
||||||
$page,
|
|
||||||
$perPage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findById(int $id): ?User
|
|
||||||
{
|
|
||||||
return $this->userRepository->findById($id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(int $id): void
|
|
||||||
{
|
|
||||||
$this->requireExistingUser($id);
|
|
||||||
$this->userRepository->delete($id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateRole(int $id, string $role): void
|
|
||||||
{
|
|
||||||
$this->assertRoleCanBeAssigned($role);
|
|
||||||
$this->requireExistingUser($id);
|
|
||||||
$this->userRepository->updateRole($id, $role);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createUser(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User
|
|
||||||
{
|
|
||||||
$username = mb_strtolower(trim($username));
|
|
||||||
$email = mb_strtolower(trim($email));
|
|
||||||
$plainPassword = trim($plainPassword);
|
|
||||||
|
|
||||||
$this->assertRoleCanBeAssigned($role);
|
|
||||||
|
|
||||||
if ($this->userRepository->findByUsername($username)) {
|
|
||||||
throw new DuplicateUsernameException($username);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->userRepository->findByEmail($email)) {
|
|
||||||
throw new DuplicateEmailException($email);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mb_strlen($plainPassword) < 8) {
|
|
||||||
throw new WeakPasswordException();
|
|
||||||
}
|
|
||||||
|
|
||||||
$passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT, ['cost' => 12]);
|
|
||||||
$user = new User(0, $username, $email, $passwordHash, $role);
|
|
||||||
|
|
||||||
$this->userRepository->create($user);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assertRoleCanBeAssigned(string $role): void
|
|
||||||
{
|
|
||||||
if (!in_array($role, User::allRoles(), true)) {
|
|
||||||
throw new InvalidRoleException($role);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!in_array($role, User::assignableRoles(), true)) {
|
|
||||||
throw new RoleAssignmentNotAllowedException($role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requireExistingUser(int $id): User
|
|
||||||
{
|
|
||||||
$user = $this->userRepository->findById($id);
|
|
||||||
|
|
||||||
if ($user === null) {
|
|
||||||
throw new NotFoundException('Utilisateur', $id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user