280 lines
8.8 KiB
PHP
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.');
|
|
}
|
|
}
|
|
}
|