Files
MtoRagSystem/src/Service/Admin/UserAdminService.php
2026-05-11 14:26:09 +02:00

280 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service\Admin;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\ApplicationRoles;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Uid\Uuid;
/**
* Encapsulates user-management rules for the admin UI.
*/
final readonly class UserAdminService
{
public function __construct(
private UserRepository $users,
private EntityManagerInterface $em,
private UserPasswordHasherInterface $passwordHasher,
) {
}
/**
* @return array{users: list<User>, total: int, filtered_total: int, active_total: int, inactive_total: int}
*/
public function listUsers(string $query = '', string $status = 'all', string $role = 'all'): array
{
$allUsers = $this->users->findForAdminList();
$normalizedQuery = strtolower(trim($query));
$normalizedStatus = in_array($status, ['all', 'active', 'inactive'], true) ? $status : 'all';
$normalizedRole = in_array($role, ApplicationRoles::assignableRoleNames(), true) ? $role : 'all';
$activeTotal = 0;
$inactiveTotal = 0;
foreach ($allUsers as $user) {
if ($user->isActive()) {
++$activeTotal;
} else {
++$inactiveTotal;
}
}
$filtered = array_values(array_filter($allUsers, static function (User $user) use ($normalizedQuery, $normalizedStatus, $normalizedRole): bool {
if ($normalizedQuery !== '' && !str_contains(strtolower($user->getEmail()), $normalizedQuery)) {
return false;
}
if ($normalizedStatus === 'active' && !$user->isActive()) {
return false;
}
if ($normalizedStatus === 'inactive' && $user->isActive()) {
return false;
}
if ($normalizedRole !== 'all' && !in_array($normalizedRole, $user->getRoles(), true)) {
return false;
}
return true;
}));
return [
'users' => $filtered,
'total' => count($allUsers),
'filtered_total' => count($filtered),
'active_total' => $activeTotal,
'inactive_total' => $inactiveTotal,
];
}
/**
* @return array<string, string>
*/
public function roleChoices(): array
{
return ApplicationRoles::assignableChoices();
}
/**
* @param list<string> $roles
*/
public function create(string $email, string $plainPassword, string $passwordRepeat, array $roles, bool $isActive): User
{
$email = $this->normalizeEmail($email);
$roles = $this->normalizeRoles($roles);
$this->assertValidEmail($email);
$this->assertEmailIsAvailable($email);
$this->assertValidPassword($plainPassword, $passwordRepeat, true);
$user = new User();
$user->setEmail($email);
$user->setRoles($roles);
$user->setIsActive($isActive);
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
$this->em->persist($user);
$this->em->flush();
return $user;
}
/**
* @param list<string> $roles
*/
public function update(
string $id,
string $email,
string $plainPassword,
string $passwordRepeat,
array $roles,
bool $isActive,
User $currentUser,
): User {
$user = $this->requireUser($id);
$email = $this->normalizeEmail($email);
$roles = $this->normalizeRoles($roles);
$this->assertValidEmail($email);
$this->assertEmailIsAvailable($email, $user);
$this->assertValidPassword($plainPassword, $passwordRepeat, false);
$this->assertCanApplySecurityState($user, $currentUser, $roles, $isActive);
$user->setEmail($email);
$user->setRoles($roles);
$user->setIsActive($isActive);
if (trim($plainPassword) !== '') {
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
}
$this->em->flush();
return $user;
}
public function toggleActive(string $id, User $currentUser): User
{
$user = $this->requireUser($id);
$nextActiveState = !$user->isActive();
$this->assertCanApplySecurityState($user, $currentUser, $this->storedAssignableRoles($user), $nextActiveState);
$user->setIsActive($nextActiveState);
$this->em->flush();
return $user;
}
public function requireUser(string $id): User
{
if (!Uuid::isValid($id)) {
throw new \RuntimeException('Benutzer wurde nicht gefunden.');
}
$user = $this->users->find(Uuid::fromString($id));
if (!$user instanceof User) {
throw new \RuntimeException('Benutzer wurde nicht gefunden.');
}
return $user;
}
private function normalizeEmail(string $email): string
{
return strtolower(trim($email));
}
private function assertValidEmail(string $email): void
{
if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new \RuntimeException('Bitte eine gültige E-Mail-Adresse eingeben.');
}
}
private function assertEmailIsAvailable(string $email, ?User $currentUser = null): void
{
$existingUser = $this->users->findOneByNormalizedEmail($email);
if (!$existingUser instanceof User) {
return;
}
if ($currentUser instanceof User && (string) $existingUser->getId() === (string) $currentUser->getId()) {
return;
}
throw new \RuntimeException('Diese E-Mail-Adresse wird bereits verwendet.');
}
private function assertValidPassword(string $plainPassword, string $passwordRepeat, bool $required): void
{
if (!$required && $plainPassword === '' && $passwordRepeat === '') {
return;
}
if (trim($plainPassword) === '') {
throw new \RuntimeException('Bitte ein Passwort eingeben.');
}
if (strlen($plainPassword) < 8) {
throw new \RuntimeException('Das Passwort muss mindestens 8 Zeichen lang sein.');
}
if ($plainPassword !== $passwordRepeat) {
throw new \RuntimeException('Passwort und Wiederholung stimmen nicht überein.');
}
}
/**
* @param list<string> $roles
* @return list<string>
*/
private function normalizeRoles(array $roles): array
{
$roles = array_values(array_unique(array_map(static fn (mixed $role): string => trim((string) $role), $roles)));
$roles = array_values(array_filter($roles, static fn (string $role): bool => $role !== '' && $role !== ApplicationRoles::ROLE_USER));
$allowedRoles = ApplicationRoles::assignableRoleNames();
$unknownRoles = array_values(array_diff($roles, $allowedRoles));
if ($unknownRoles !== []) {
throw new \RuntimeException('Unbekannte Rolle: ' . implode(', ', $unknownRoles));
}
$orderedRoles = [];
foreach ($allowedRoles as $allowedRole) {
if (in_array($allowedRole, $roles, true)) {
$orderedRoles[] = $allowedRole;
}
}
if ($orderedRoles === []) {
throw new \RuntimeException('Bitte mindestens eine Rolle auswählen.');
}
return $orderedRoles;
}
/**
* @return list<string>
*/
private function storedAssignableRoles(User $user): array
{
return array_values(array_intersect(ApplicationRoles::assignableRoleNames(), $user->getRoles()));
}
/**
* @param list<string> $newRoles
*/
private function assertCanApplySecurityState(User $targetUser, User $currentUser, array $newRoles, bool $newActiveState): void
{
$isSelf = (string) $targetUser->getId() === (string) $currentUser->getId();
$currentlySuperAdmin = in_array(ApplicationRoles::ROLE_SUPER_ADMIN, $targetUser->getRoles(), true);
$willBeSuperAdmin = in_array(ApplicationRoles::ROLE_SUPER_ADMIN, $newRoles, true);
if ($isSelf && !$newActiveState) {
throw new \RuntimeException('Du kannst deinen eigenen Benutzer nicht deaktivieren.');
}
if ($isSelf && $currentlySuperAdmin && !$willBeSuperAdmin) {
throw new \RuntimeException('Du kannst dir die Super-Admin-Rolle nicht selbst entziehen.');
}
$removesActiveSuperAdmin = $targetUser->isActive()
&& $currentlySuperAdmin
&& (!$newActiveState || !$willBeSuperAdmin);
if ($removesActiveSuperAdmin && $this->users->countActiveSuperAdmins($targetUser) < 1) {
throw new \RuntimeException('Der letzte aktive Super-Admin darf nicht deaktiviert oder demotet werden.');
}
}
}