first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\User\Exception;
/**
* Exception levée lorsqu'une adresse e-mail est déjà utilisée.
*
* Permet aux appelants de distinguer cette erreur métier précise
* d'une InvalidArgumentException générique sans analyser le message.
*/
final class DuplicateEmailException extends \InvalidArgumentException
{
public function __construct(string $email)
{
parent::__construct("Cette adresse e-mail est déjà utilisée : {$email}");
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\User\Exception;
/**
* Exception levée lorsqu'un nom d'utilisateur est déjà pris.
*
* Permet aux appelants de distinguer cette erreur métier précise
* d'une InvalidArgumentException générique sans analyser le message.
*/
final class DuplicateUsernameException extends \InvalidArgumentException
{
public function __construct(string $username)
{
parent::__construct("Ce nom d'utilisateur est déjà pris : {$username}");
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\User\Exception;
use App\User\User;
/**
* Exception levée lorsqu'un rôle utilisateur non autorisé est demandé.
*/
final class InvalidRoleException extends \InvalidArgumentException
{
public function __construct(string $role)
{
$validRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN];
parent::__construct(
"Le rôle '{$role}' est invalide. Valeurs autorisées : " . implode(', ', $validRoles)
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\User\Exception;
/**
* Exception levée lorsqu'un mot de passe ne respecte pas les règles de complexité.
*
* Permet aux appelants de distinguer cette erreur métier précise
* d'une InvalidArgumentException générique sans analyser le message.
*/
final class WeakPasswordException extends \InvalidArgumentException
{
/**
* @param int $minLength Longueur minimale requise
*/
public function __construct(int $minLength = 8)
{
parent::__construct("Le mot de passe doit contenir au moins {$minLength} caractères");
}
}

191
src/User/User.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\User;
use App\Shared\Util\DateParser;
use DateTime;
/**
* Modèle représentant un utilisateur de l'application.
*
* Encapsule les données et la validation d'un compte utilisateur.
* Ce modèle est immuable : toutes les propriétés sont en lecture seule
* après construction.
*/
final class User
{
/**
* @var DateTime Date de création — toujours non nulle après construction
* (le constructeur accepte ?DateTime mais affecte `new DateTime()` si null)
*/
private readonly DateTime $createdAt;
/**
* Rôles valides pour un utilisateur.
*/
public const ROLE_USER = 'user';
public const ROLE_EDITOR = 'editor';
public const ROLE_ADMIN = 'admin';
/**
* @param int $id Identifiant en base (0 pour un nouvel utilisateur)
* @param string $username Nom d'utilisateur (normalisé en minuscules)
* @param string $email Adresse e-mail (normalisée en minuscules)
* @param string $passwordHash Hash bcrypt du mot de passe
* @param string $role Rôle de l'utilisateur : 'user', 'editor' ou 'admin'
* @param DateTime|null $createdAt Date de création (défaut : maintenant)
*
* @throws \InvalidArgumentException Si les données ne passent pas la validation
*/
public function __construct(
private readonly int $id,
private readonly string $username,
private readonly string $email,
private readonly string $passwordHash,
private readonly string $role = self::ROLE_USER,
?DateTime $createdAt = null,
) {
$this->createdAt = $createdAt ?? new DateTime();
$this->validate();
}
/**
* Crée une instance depuis un tableau associatif (ligne de base de données).
*
* @param array<string, mixed> $data Données issues de la base de données
*
* @return self L'instance hydratée
*/
public static function fromArray(array $data): self
{
return new self(
id: (int) ($data['id'] ?? 0),
username: (string) ($data['username'] ?? ''),
email: (string) ($data['email'] ?? ''),
passwordHash: (string) ($data['password_hash'] ?? ''),
role: (string) ($data['role'] ?? self::ROLE_USER),
createdAt: DateParser::parse($data['created_at'] ?? null),
);
}
/**
* Retourne l'identifiant de l'utilisateur.
*
* @return int L'identifiant en base (0 si non encore persisté)
*/
public function getId(): int
{
return $this->id;
}
/**
* Retourne le nom d'utilisateur.
*
* @return string Le nom d'utilisateur normalisé en minuscules
*/
public function getUsername(): string
{
return $this->username;
}
/**
* Retourne l'adresse e-mail.
*
* @return string L'adresse e-mail normalisée en minuscules
*/
public function getEmail(): string
{
return $this->email;
}
/**
* Retourne le hash bcrypt du mot de passe.
*
* @return string Le hash bcrypt
*/
public function getPasswordHash(): string
{
return $this->passwordHash;
}
/**
* Retourne le rôle de l'utilisateur.
*
* @return string 'user', 'editor' ou 'admin'
*/
public function getRole(): string
{
return $this->role;
}
/**
* Indique si l'utilisateur a le rôle administrateur.
*
* @return bool True si l'utilisateur est administrateur
*/
public function isAdmin(): bool
{
return $this->role === self::ROLE_ADMIN;
}
/**
* Indique si l'utilisateur a le rôle éditeur.
*
* @return bool True si l'utilisateur est éditeur
*/
public function isEditor(): bool
{
return $this->role === self::ROLE_EDITOR;
}
/**
* Retourne la date de création du compte.
*
* @return DateTime La date de création
*/
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
/**
* Valide les données de l'utilisateur.
*
* @throws \InvalidArgumentException Si le nom d'utilisateur fait moins de 3 ou plus de 50 caractères
* @throws \InvalidArgumentException Si l'adresse e-mail est invalide ou vide
* @throws \InvalidArgumentException Si le hash du mot de passe est vide
* @throws \InvalidArgumentException Si le rôle n'est pas une valeur autorisée
*/
private function validate(): void
{
if (mb_strlen($this->username) < 3) {
throw new \InvalidArgumentException(
"Le nom d'utilisateur doit contenir au moins 3 caractères"
);
}
if (mb_strlen($this->username) > 50) {
throw new \InvalidArgumentException(
"Le nom d'utilisateur ne peut pas dépasser 50 caractères"
);
}
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("L'email n'est pas valide");
}
if ($this->passwordHash === '') {
throw new \InvalidArgumentException(
'Le hash du mot de passe ne peut pas être vide'
);
}
$validRoles = [self::ROLE_USER, self::ROLE_EDITOR, self::ROLE_ADMIN];
if (!in_array($this->role, $validRoles, true)) {
throw new \InvalidArgumentException(
"Le rôle '{$this->role}' est invalide. Valeurs autorisées : " . implode(', ', $validRoles)
);
}
}
}

234
src/User/UserController.php Normal file
View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace App\User;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\WeakPasswordException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
/**
* Contrôleur pour la gestion des utilisateurs en administration.
*
* Accessible uniquement aux administrateurs (AdminMiddleware).
* Gère la liste, la création, la modification de rôle et la suppression des comptes.
* Toute la logique de persistance est déléguée à UserService.
*
* Règles de protection communes :
* - Le compte administrateur (role = 'admin') ne peut pas être supprimé ni rétrogradé
* - Un administrateur ne peut pas supprimer son propre compte ni changer son propre rôle
*/
final class UserController
{
/**
* @param Twig $view Moteur de templates Twig
* @param UserServiceInterface $userService Service de gestion des utilisateurs
* @param FlashServiceInterface $flash Service de messages flash
* @param SessionManagerInterface $sessionManager Gestionnaire de session
*/
public function __construct(
private readonly Twig $view,
private readonly UserServiceInterface $userService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
) {
}
/**
* Affiche la liste de tous les utilisateurs.
*
* Passe l'identifiant de l'utilisateur courant à la vue
* pour conditionner l'affichage du bouton de suppression.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue admin/users/index.twig
*/
public function index(Request $req, Response $res): Response
{
return $this->view->render($res, 'admin/users/index.twig', [
'users' => $this->userService->findAll(),
'currentUserId' => $this->sessionManager->getUserId(),
'error' => $this->flash->get('user_error'),
'success' => $this->flash->get('user_success'),
]);
}
/**
* Affiche le formulaire de création d'un utilisateur.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue admin/users/form.twig
*/
public function showCreate(Request $req, Response $res): Response
{
return $this->view->render($res, 'admin/users/form.twig', [
'error' => $this->flash->get('user_error'),
]);
}
/**
* Traite la soumission du formulaire de création d'utilisateur.
*
* Vérifie que les mots de passe correspondent avant de déléguer
* la création à UserService. En cas d'erreur, redirige vers le
* formulaire avec un message flash.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Redirection vers /admin/users en cas de succès,
* ou vers /admin/users/create en cas d'erreur
*/
public function create(Request $req, Response $res): Response
{
/** @var array<string, mixed> $data */
$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'] ?? ''));
// Restreindre les rôles assignables depuis le formulaire.
// Le rôle 'admin' est exclu : il ne peut être attribué que directement
// en base de données, pour éviter qu'un admin ne crée d'autres admins
// en manipulant la requête HTTP.
$allowedRoles = [User::ROLE_USER, User::ROLE_EDITOR];
$rawRole = trim((string) ($data['role'] ?? ''));
$role = in_array($rawRole, $allowedRoles, 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 $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);
}
/**
* Met à jour le rôle d'un utilisateur.
*
* La modification est refusée dans trois cas :
* - l'utilisateur cible est introuvable
* - l'administrateur connecté tente de modifier son propre rôle
* - l'utilisateur cible est déjà administrateur
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (id)
*
* @return Response Redirection vers /admin/users dans tous les cas
*/
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é');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
$allowedRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN];
/** @var array<string, mixed> $body */
$body = (array) $req->getParsedBody();
$rawRole = trim((string) ($body['role'] ?? ''));
$role = in_array($rawRole, $allowedRoles, true) ? $rawRole : null;
if ($role === null) {
$this->flash->set('user_error', 'Rôle invalide');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
$this->userService->updateRole($id, $role);
$this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour");
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
/**
* Supprime un utilisateur.
*
* La suppression est refusée dans trois cas :
* - l'utilisateur cible est introuvable
* - l'utilisateur cible est administrateur (role = 'admin')
* - l'administrateur connecté tente de supprimer son propre compte
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (id)
*
* @return Response Redirection vers /admin/users dans tous les cas
*/
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);
}
}

150
src/User/UserRepository.php Normal file
View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\User;
use PDO;
/**
* Dépôt pour la persistance des utilisateurs.
*
* Responsabilité unique : exécuter les requêtes SQL liées à la table `users`
* et retourner des instances de User hydratées.
*/
final class UserRepository implements UserRepositoryInterface
{
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db)
{
}
/**
* Retourne tous les utilisateurs triés par date de création.
*
* @return User[] La liste des utilisateurs
*/
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é.');
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => User::fromArray($row), $rows);
}
/**
* Trouve un utilisateur par son identifiant.
*
* @param int $id Identifiant de l'utilisateur
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
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;
}
/**
* Trouve un utilisateur par son nom d'utilisateur (insensible à la casse).
*
* @param string $username Nom d'utilisateur normalisé en minuscules
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
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;
}
/**
* Trouve un utilisateur par son adresse e-mail (insensible à la casse).
*
* @param string $email Adresse e-mail normalisée en minuscules
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
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;
}
/**
* Persiste un nouvel utilisateur en base de données.
*
* @param User $user L'utilisateur à créer
*
* @return int L'identifiant généré par la base de données
*/
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();
}
/**
* Met à jour le hash du mot de passe d'un utilisateur.
*
* @param int $id Identifiant de l'utilisateur
* @param string $newHash Nouveau hash bcrypt
*/
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]);
}
/**
* Met à jour le rôle d'un utilisateur.
*
* @param int $id Identifiant de l'utilisateur
* @param string $role Nouveau rôle : 'user', 'editor' ou 'admin'
*/
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]);
}
/**
* Supprime un utilisateur de la base de données.
*
* @param int $id Identifiant de l'utilisateur à supprimer
* @return void
*/
public function delete(int $id): void
{
$stmt = $this->db->prepare('DELETE FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\User;
/**
* Contrat de persistance des utilisateurs.
*
* Découple les services métier de l'implémentation concrète (PDO/SQLite),
* facilitant les mocks dans les tests et un éventuel changement de stockage.
*/
interface UserRepositoryInterface
{
/**
* Retourne tous les utilisateurs triés par date de création.
*
* @return User[]
*/
public function findAll(): array;
/**
* Trouve un utilisateur par son identifiant.
*
* @param int $id Identifiant de l'utilisateur
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?User;
/**
* Trouve un utilisateur par son nom d'utilisateur.
*
* @param string $username Nom d'utilisateur normalisé en minuscules
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findByUsername(string $username): ?User;
/**
* Trouve un utilisateur par son adresse e-mail.
*
* @param string $email Adresse e-mail normalisée en minuscules
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findByEmail(string $email): ?User;
/**
* Persiste un nouvel utilisateur en base de données.
*
* @param User $user L'utilisateur à créer
*
* @return int L'identifiant généré par la base de données
*/
public function create(User $user): int;
/**
* Met à jour le hash du mot de passe d'un utilisateur.
*
* @param int $id Identifiant de l'utilisateur
* @param string $newHash Nouveau hash bcrypt
*/
public function updatePassword(int $id, string $newHash): void;
/**
* Met à jour le rôle d'un utilisateur.
*
* @param int $id Identifiant de l'utilisateur
* @param string $role Nouveau rôle : 'user', 'editor' ou 'admin'
*/
public function updateRole(int $id, string $role): void;
/**
* Supprime un utilisateur de la base de données.
*
* @param int $id Identifiant de l'utilisateur à supprimer
* @return void
*/
public function delete(int $id): void;
}

89
src/User/UserService.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\User;
use App\Shared\Exception\NotFoundException;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\WeakPasswordException;
final class UserService implements UserServiceInterface
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
) {
}
public function findAll(): array
{
return $this->userRepository->findAll();
}
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->assertValidRole($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->assertValidRole($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 assertValidRole(string $role): void
{
$validRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN];
if (!in_array($role, $validRoles, true)) {
throw new InvalidRoleException($role);
}
}
private function requireExistingUser(int $id): User
{
$user = $this->userRepository->findById($id);
if ($user === null) {
throw new NotFoundException('Utilisateur', $id);
}
return $user;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\User;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\WeakPasswordException;
/**
* Contrat du service de gestion des utilisateurs.
*
* Permet de mocker le service dans les tests unitaires sans dépendre
* de la classe concrète finale UserService.
*/
interface UserServiceInterface
{
/**
* Retourne tous les utilisateurs triés par date de création (ordre croissant).
*
* @return User[]
*/
public function findAll(): array;
/**
* Trouve un utilisateur par son identifiant.
*
* @param int $id Identifiant de l'utilisateur
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?User;
/**
* Supprime un utilisateur de la base de données.
*
* @param int $id Identifiant de l'utilisateur à supprimer
*/
public function delete(int $id): void;
/**
* Crée un nouveau compte utilisateur.
*
* @param string $username Nom d'utilisateur souhaité (min. 3 caractères)
* @param string $email Adresse e-mail valide
* @param string $plainPassword Mot de passe en clair (min. 8 caractères)
* @param string $role Rôle attribué : 'user', 'editor' ou 'admin' (défaut : 'user')
*
* @return User L'utilisateur créé (sans mot de passe en clair)
*
* @throws DuplicateUsernameException Si le nom d'utilisateur est déjà pris
* @throws DuplicateEmailException Si l'adresse e-mail est déjà utilisée
* @throws WeakPasswordException Si le mot de passe est trop court
* @throws InvalidRoleException Si le rôle est invalide
* @throws \InvalidArgumentException Si le nom ou l'email ne passent pas la validation
*/
public function createUser(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User;
/**
* Met à jour le rôle d'un utilisateur.
*
* @param int $id Identifiant de l'utilisateur
* @param string $role Nouveau rôle : 'user', 'editor' ou 'admin'
*
* @throws InvalidRoleException Si le rôle est invalide
*/
public function updateRole(int $id, string $role): void;
}