add user management

This commit is contained in:
team 1
2026-05-11 14:26:09 +02:00
parent 4d9ba6c7fc
commit acb1082398
13 changed files with 1246 additions and 66 deletions

View File

@@ -16,6 +16,7 @@ security:
pattern: ^/admin pattern: ^/admin
lazy: true lazy: true
provider: app_user_provider provider: app_user_provider
user_checker: App\Security\ActiveUserChecker
context: retriex_user_area context: retriex_user_area
form_login: form_login:
@@ -37,6 +38,7 @@ security:
pattern: ^/(?:$|chat(?:/|$)|ask-jobs(?:/|$)|ask-sse(?:/|$)|history(?:/|$)|chat-messages/frontend$) pattern: ^/(?:$|chat(?:/|$)|ask-jobs(?:/|$)|ask-sse(?:/|$)|history(?:/|$)|chat-messages/frontend$)
lazy: true lazy: true
provider: app_user_provider provider: app_user_provider
user_checker: App\Security\ActiveUserChecker
context: retriex_user_area context: retriex_user_area
form_login: form_login:
@@ -68,6 +70,7 @@ security:
access_control: access_control:
- { path: ^/admin/login$, roles: PUBLIC_ACCESS } - { path: ^/admin/login$, roles: PUBLIC_ACCESS }
- { path: ^/admin/logout$, roles: PUBLIC_ACCESS } - { path: ^/admin/logout$, roles: PUBLIC_ACCESS }
- { path: ^/admin/users, roles: ROLE_SUPER_ADMIN }
- { path: ^/admin, roles: ROLE_ADMIN_AREA } - { path: ^/admin, roles: ROLE_ADMIN_AREA }
- { path: ^/chat/login$, roles: PUBLIC_ACCESS } - { path: ^/chat/login$, roles: PUBLIC_ACCESS }

View File

@@ -0,0 +1,148 @@
# RETRIEX PATCH 93 - Admin User Management CRUD
## Ziel
Ergaenzt eine saubere Benutzerverwaltung im Adminbereich, damit neue Benutzer nicht mehr nur per Console angelegt werden muessen.
Der Patch fuegt CRUD-nahe Adminfunktionen fuer Benutzer hinzu:
- Benutzerliste im Adminbereich
- Benutzer anlegen
- Benutzer bearbeiten
- Rollen zuweisen
- Passwort setzen bzw. zuruecksetzen
- Benutzer aktivieren/deaktivieren
- Self-Protection gegen versehentliches Aussperren
- Login-Blocker fuer deaktivierte Benutzer
## Architektur
Der Patch greift nicht in RAG-, Retrieval-, Scoring-, Prompt-, Shop- oder Chat-Antwortlogik ein.
Neue Admin-Route:
- `GET /admin/users`
- `GET /admin/users/create`
- `POST /admin/users/create`
- `GET /admin/users/{id}/edit`
- `POST /admin/users/{id}/edit`
- `POST /admin/users/{id}/toggle-active`
Alle Routen sind nur fuer `ROLE_SUPER_ADMIN` erreichbar.
## Neue Dateien
- `src/Controller/Admin/UserController.php`
- `src/Service/Admin/UserAdminService.php`
- `src/Security/ApplicationRoles.php`
- `src/Security/ActiveUserChecker.php`
- `src/Security/ActiveUserSessionSubscriber.php`
- `templates/admin/user/index.html.twig`
- `templates/admin/user/create.html.twig`
- `templates/admin/user/edit.html.twig`
## Geaenderte Dateien
- `config/packages/security.yaml`
- `templates/admin/base.html.twig`
- `src/Repository/UserRepository.php`
- `src/Command/CreateUserCommand.php`
## Optimierungen gegenueber dem ersten Konzept
- Rollen werden zentral in `ApplicationRoles` gepflegt.
- Admin-UI und Console-Command nutzen dieselbe Rollenquelle.
- Benutzerliste hat einfache Filter fuer Suche, Status und Rolle.
- `isActive` ist nicht nur ein UI-Feld, sondern wird ueber `ActiveUserChecker` beim Login erzwungen.
- Bereits angemeldete deaktivierte Benutzer werden ueber `ActiveUserSessionSubscriber` aus der Session abgemeldet.
- Kein hartes Delete im ersten Schritt, um Referenzprobleme mit Dokumenten, Versionen oder Jobs zu vermeiden.
## Rollen
Zuweisbare Rollen:
- `ROLE_SUPER_ADMIN`
- `ROLE_KNOWLEDGE_ADMIN`
- `ROLE_EDITOR`
- `ROLE_ADMIN_AREA`
- `ROLE_CHAT_USER`
`ROLE_USER` bleibt bewusst nicht manuell auswaehlbar, weil `User::getRoles()` diese Rolle automatisch ergaenzt.
## Schutzregeln
Der Patch verhindert:
- eigenen Benutzer deaktivieren
- eigene `ROLE_SUPER_ADMIN`-Rolle entfernen
- letzten aktiven Super-Admin deaktivieren
- letzten aktiven Super-Admin demoten
- Login mit deaktiviertem Benutzer
- Weiterbenutzung bereits bestehender Sessions nach Deaktivierung
## Console-Command
`mto:agent:user:create` bleibt erhalten und wurde vereinheitlicht:
- nutzt `ApplicationRoles`
- erlaubt mehrere Rollen per Mehrfachauswahl
- fragt Passwort-Wiederholung ab
- fragt Aktivstatus ab
## Lokale Checks
Ausgefuehrt im Patch-Arbeitsstand:
```bash
php -l src/Security/ApplicationRoles.php
php -l src/Security/ActiveUserChecker.php
php -l src/Security/ActiveUserSessionSubscriber.php
php -l src/Repository/UserRepository.php
php -l src/Service/Admin/UserAdminService.php
php -l src/Controller/Admin/UserController.php
php -l src/Command/CreateUserCommand.php
python3 -c "import yaml; yaml.safe_load(open('config/packages/security.yaml'))"
```
Twig wurde per Delimiter-Smoke-Test geprueft.
Nicht lokal ausfuehrbar, weil `vendor/` im ZIP nicht enthalten ist:
```bash
php bin/console lint:yaml config/packages/security.yaml
php bin/console lint:twig templates/admin/user
php bin/console debug:router | grep admin_users
```
## Empfohlene Checks in der Zielumgebung
```bash
php bin/console cache:clear
php bin/console lint:yaml config/packages/security.yaml
php bin/console lint:twig templates/admin/user
php bin/console debug:router | grep admin_users
php bin/console mto:agent:config:validate
php bin/console mto:agent:regression:test
php bin/console mto:agent:config:audit-source --details
```
## Manuelle Testfaelle
1. Als `ROLE_SUPER_ADMIN` `/admin/users` oeffnen.
2. Neuen Chat-User mit `ROLE_CHAT_USER` anlegen.
3. Mit diesem User im Chat einloggen.
4. User deaktivieren und Login im Chat pruefen.
5. User wieder aktivieren und Login pruefen.
6. Passwort im Edit-Formular setzen und Login pruefen.
7. Versuch: eigener Benutzer deaktivieren -> muss blockieren.
8. Versuch: eigene `ROLE_SUPER_ADMIN` entfernen -> muss blockieren.
9. Versuch: letzter aktiver Super-Admin deaktivieren/demoten -> muss blockieren.
## Bewusst nicht enthalten
- hartes Loeschen von Benutzern
- Passwort-Reset per E-Mail
- Einladungslinks
- Audit-Log fuer Useraenderungen
- 2FA
- Mandantenfaehigkeit

View File

@@ -1,9 +1,12 @@
<?php <?php
declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\Entity\User; use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\ApplicationRoles;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
@@ -11,6 +14,7 @@ use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Question\Question;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -18,13 +22,13 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
name: 'mto:agent:user:create', name: 'mto:agent:user:create',
description: 'Creates a new application user' description: 'Creates a new application user'
)] )]
class CreateUserCommand extends Command final class CreateUserCommand extends Command
{ {
public function __construct( public function __construct(
private EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private UserPasswordHasherInterface $passwordHasher private readonly UserRepository $users,
) private readonly UserPasswordHasherInterface $passwordHasher,
{ ) {
parent::__construct(); parent::__construct();
} }
@@ -33,76 +37,81 @@ class CreateUserCommand extends Command
/** @var QuestionHelper $helper */ /** @var QuestionHelper $helper */
$helper = $this->getHelper('question'); $helper = $this->getHelper('question');
// =============================
// Email
// =============================
$emailQuestion = new Question('E-Mail: '); $emailQuestion = new Question('E-Mail: ');
$emailQuestion->setValidator(function ($value) { $emailQuestion->setValidator(function (mixed $value): string {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { $email = strtolower(trim((string) $value));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid email address.'); throw new \RuntimeException('Invalid email address.');
} }
return strtolower(trim($value));
return $email;
}); });
$email = $helper->ask($input, $output, $emailQuestion); $email = $helper->ask($input, $output, $emailQuestion);
// Prüfen ob User existiert if ($this->users->findOneByNormalizedEmail($email) instanceof User) {
$existingUser = $this->em
->getRepository(User::class)
->findOneBy(['email' => $email]);
if ($existingUser) {
$output->writeln('<error>User already exists.</error>'); $output->writeln('<error>User already exists.</error>');
return Command::FAILURE; return Command::FAILURE;
} }
// =============================
// Passwort
// =============================
$passwordQuestion = new Question('Password: '); $passwordQuestion = new Question('Password: ');
$passwordQuestion->setHidden(true); $passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false); $passwordQuestion->setHiddenFallback(false);
$plainPassword = $helper->ask($input, $output, $passwordQuestion); $plainPassword = (string) $helper->ask($input, $output, $passwordQuestion);
if (strlen($plainPassword) < 8) { if (strlen(trim($plainPassword)) < 8) {
$output->writeln('<error>Password must be at least 8 characters.</error>'); $output->writeln('<error>Password must be at least 8 characters.</error>');
return Command::FAILURE;
}
$passwordRepeatQuestion = new Question('Repeat password: ');
$passwordRepeatQuestion->setHidden(true);
$passwordRepeatQuestion->setHiddenFallback(false);
$passwordRepeat = (string) $helper->ask($input, $output, $passwordRepeatQuestion);
if ($plainPassword !== $passwordRepeat) {
$output->writeln('<error>Passwords do not match.</error>');
return Command::FAILURE; return Command::FAILURE;
} }
// =============================
// Rolle auswählen
// =============================
$roleQuestion = new ChoiceQuestion( $roleQuestion = new ChoiceQuestion(
'Select role:', 'Select role(s), comma-separated if needed:',
[ ApplicationRoles::assignableRoleNames(),
'ROLE_SUPER_ADMIN', '0'
'ROLE_KNOWLEDGE_ADMIN',
'ROLE_EDITOR',
'ROLE_ADMIN_AREA',
'ROLE_CHAT_USER',
],
0
); );
$roleQuestion->setMultiselect(true);
$role = $helper->ask($input, $output, $roleQuestion); $roles = $helper->ask($input, $output, $roleQuestion);
$roles = is_array($roles) ? array_values(array_unique(array_map('strval', $roles))) : [];
if ($roles === []) {
$output->writeln('<error>At least one role is required.</error>');
return Command::FAILURE;
}
$activeQuestion = new ConfirmationQuestion('Activate user? [Y/n] ', true);
$isActive = (bool) $helper->ask($input, $output, $activeQuestion);
// =============================
// User erzeugen
// =============================
$user = new User(); $user = new User();
$user->setEmail($email); $user->setEmail($email);
$user->setRoles([$role]); $user->setRoles($roles);
$user->setIsActive($isActive);
$hashedPassword = $this->passwordHasher->hashPassword($user, $plainPassword); $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
$user->setPassword($hashedPassword);
$this->em->persist($user); $this->em->persist($user);
$this->em->flush(); $this->em->flush();
$output->writeln('<info>User created successfully.</info>'); $output->writeln('<info>User created successfully.</info>');
$output->writeln('Email: ' . $email); $output->writeln('Email: ' . $email);
$output->writeln('Role: ' . $role); $output->writeln('Roles: ' . implode(', ', $roles));
$output->writeln('Active: ' . ($isActive ? 'yes' : 'no'));
return Command::SUCCESS; return Command::SUCCESS;
} }

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\User;
use App\Service\Admin\UserAdminService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\User\UserInterface;
#[Route('/admin/users')]
final class UserController extends AbstractController
{
#[Route('', name: 'admin_users_index', methods: ['GET'])]
public function index(Request $request, UserAdminService $users): Response
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$query = trim((string) $request->query->get('q', ''));
$status = (string) $request->query->get('status', 'all');
$role = (string) $request->query->get('role', 'all');
$list = $users->listUsers($query, $status, $role);
return $this->render('admin/user/index.html.twig', [
'users' => $list['users'],
'total' => $list['total'],
'filtered_total' => $list['filtered_total'],
'active_total' => $list['active_total'],
'inactive_total' => $list['inactive_total'],
'role_choices' => $users->roleChoices(),
'filters' => [
'q' => $query,
'status' => in_array($status, ['all', 'active', 'inactive'], true) ? $status : 'all',
'role' => array_key_exists($role, $users->roleChoices()) ? $role : 'all',
],
]);
}
#[Route('/create', name: 'admin_users_create', methods: ['GET', 'POST'])]
public function create(Request $request, UserAdminService $users): Response
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
if (!$request->isMethod('POST')) {
return $this->render('admin/user/create.html.twig', [
'role_choices' => $users->roleChoices(),
]);
}
if (!$this->isCsrfTokenValid('admin_user_create', (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
return $this->redirectToRoute('admin_users_create');
}
try {
$user = $users->create(
(string) $request->request->get('email', ''),
(string) $request->request->get('password', ''),
(string) $request->request->get('password_repeat', ''),
$this->extractRoles($request),
$request->request->has('is_active'),
);
$this->addFlash('success', 'Benutzer wurde erstellt: ' . $user->getEmail());
return $this->redirectToRoute('admin_users_index');
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
return $this->redirectToRoute('admin_users_create');
}
}
#[Route('/{id}/edit', name: 'admin_users_edit', requirements: ['id' => '[0-9a-fA-F\-]{36}'], methods: ['GET', 'POST'])]
public function edit(string $id, Request $request, UserAdminService $users): Response
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
try {
$user = $users->requireUser($id);
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
return $this->redirectToRoute('admin_users_index');
}
if (!$request->isMethod('POST')) {
return $this->render('admin/user/edit.html.twig', [
'managed_user' => $user,
'role_choices' => $users->roleChoices(),
]);
}
if (!$this->isCsrfTokenValid('admin_user_edit_' . $id, (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
return $this->redirectToRoute('admin_users_edit', ['id' => $id]);
}
try {
$updatedUser = $users->update(
$id,
(string) $request->request->get('email', ''),
(string) $request->request->get('password', ''),
(string) $request->request->get('password_repeat', ''),
$this->extractRoles($request),
$request->request->has('is_active'),
$this->requireCurrentUser(),
);
$this->addFlash('success', 'Benutzer wurde aktualisiert: ' . $updatedUser->getEmail());
return $this->redirectToRoute('admin_users_index');
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
return $this->redirectToRoute('admin_users_edit', ['id' => $id]);
}
}
#[Route('/{id}/toggle-active', name: 'admin_users_toggle_active', requirements: ['id' => '[0-9a-fA-F\-]{36}'], methods: ['POST'])]
public function toggleActive(string $id, Request $request, UserAdminService $users): RedirectResponse
{
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
if (!$this->isCsrfTokenValid('admin_user_toggle_active_' . $id, (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
return $this->redirectToRoute('admin_users_index');
}
try {
$user = $users->toggleActive($id, $this->requireCurrentUser());
$this->addFlash('success', sprintf(
'Benutzer %s wurde %s.',
$user->getEmail(),
$user->isActive() ? 'aktiviert' : 'deaktiviert',
));
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
}
return $this->redirectToRoute('admin_users_index');
}
/**
* @return list<string>
*/
private function extractRoles(Request $request): array
{
$roles = $request->request->all('roles');
if (!is_array($roles)) {
return [];
}
return array_values(array_map(static fn (mixed $role): string => (string) $role, $roles));
}
private function requireCurrentUser(): User
{
$user = $this->getUser();
if (!$user instanceof UserInterface || !$user instanceof User) {
throw $this->createAccessDeniedException('Kein gültiger Benutzer angemeldet.');
}
return $user;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use App\Security\ApplicationRoles;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@@ -16,28 +17,42 @@ class UserRepository extends ServiceEntityRepository
parent::__construct($registry, User::class); parent::__construct($registry, User::class);
} }
// /** /**
// * @return User[] Returns an array of User objects * @return list<User>
// */ */
// public function findByExampleField($value): array public function findForAdminList(): array
// { {
// return $this->createQueryBuilder('u') return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val') ->orderBy('u.email', 'ASC')
// ->setParameter('val', $value) ->getQuery()
// ->orderBy('u.id', 'ASC') ->getResult();
// ->setMaxResults(10) }
// ->getQuery()
// ->getResult() public function findOneByNormalizedEmail(string $email): ?User
// ; {
// } $normalizedEmail = strtolower(trim($email));
// public function findOneBySomeField($value): ?User if ($normalizedEmail === '') {
// { return null;
// return $this->createQueryBuilder('u') }
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value) return $this->findOneBy(['email' => $normalizedEmail]);
// ->getQuery() }
// ->getOneOrNullResult()
// ; public function countActiveSuperAdmins(?User $exclude = null): int
// } {
$count = 0;
foreach ($this->findBy(['isActive' => true]) as $user) {
if ($exclude instanceof User && (string) $user->getId() === (string) $exclude->getId()) {
continue;
}
if (in_array(ApplicationRoles::ROLE_SUPER_ADMIN, $user->getRoles(), true)) {
++$count;
}
}
return $count;
}
} }

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use Symfony\Component\Security\Core\Exception\DisabledException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Blocks login for users that were deactivated in the admin area.
*/
final class ActiveUserChecker implements UserCheckerInterface
{
public function checkPreAuth(UserInterface $user): void
{
if ($user instanceof User && !$user->isActive()) {
throw new DisabledException('Dieser Benutzer ist deaktiviert.');
}
}
public function checkPostAuth(UserInterface $user): void
{
// No post-auth checks required.
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* Invalidates already-authenticated sessions when an admin deactivates a user.
*/
final readonly class ActiveUserSessionSubscriber implements EventSubscriberInterface
{
public function __construct(
private TokenStorageInterface $tokenStorage,
private UrlGeneratorInterface $urlGenerator,
) {
}
/**
* @return array<string, mixed>
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 8],
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$token = $this->tokenStorage->getToken();
if ($token === null) {
return;
}
$user = $token->getUser();
if (!$user instanceof User || $user->isActive()) {
return;
}
$this->tokenStorage->setToken(null);
$request = $event->getRequest();
if ($request->hasSession()) {
$request->getSession()->invalidate();
}
$route = str_starts_with($request->getPathInfo(), '/admin') ? 'admin_login' : 'chat_login';
$event->setResponse(new RedirectResponse($this->urlGenerator->generate($route)));
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Security;
/**
* Central list of application roles that may be assigned by administrators.
*/
final class ApplicationRoles
{
public const ROLE_SUPER_ADMIN = 'ROLE_SUPER_ADMIN';
public const ROLE_KNOWLEDGE_ADMIN = 'ROLE_KNOWLEDGE_ADMIN';
public const ROLE_EDITOR = 'ROLE_EDITOR';
public const ROLE_ADMIN_AREA = 'ROLE_ADMIN_AREA';
public const ROLE_CHAT_USER = 'ROLE_CHAT_USER';
public const ROLE_USER = 'ROLE_USER';
/**
* @return array<string, string>
*/
public static function assignableChoices(): array
{
return [
self::ROLE_SUPER_ADMIN => 'Super Admin',
self::ROLE_KNOWLEDGE_ADMIN => 'Knowledge Admin',
self::ROLE_EDITOR => 'Editor',
self::ROLE_ADMIN_AREA => 'Adminbereich',
self::ROLE_CHAT_USER => 'Chat User',
];
}
/**
* @return list<string>
*/
public static function assignableRoleNames(): array
{
return array_keys(self::assignableChoices());
}
public static function label(string $role): string
{
return self::assignableChoices()[$role] ?? $role;
}
}

View File

@@ -0,0 +1,279 @@
<?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.');
}
}
}

View File

@@ -64,6 +64,13 @@
<i class="bi bi-hdd-rack"></i> Systemübersicht <i class="bi bi-hdd-rack"></i> Systemübersicht
</a> </a>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<a class="nav-link text-light {% if route starts with 'admin_users' %}active fw-bold{% endif %}"
href="{{ path('admin_users_index') }}">
<i class="bi bi-people-fill"></i> Benutzer
</a>
{% endif %}
<hr class="border-secondary"> <hr class="border-secondary">
<div class="text-info text-uppercase small mb-2"> <div class="text-info text-uppercase small mb-2">

View File

@@ -0,0 +1,106 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Benutzer anlegen{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-person-plus-fill"></i> Benutzer anlegen
</h1>
<div class="small text-muted">
Neuer Zugang für Chat, Adminbereich oder Systempflege.
</div>
</div>
<a href="{{ path('admin_users_index') }}" class="btn btn-sm btn-outline-secondary">
Zurück zur Liste
</a>
</div>
{% for message in app.flashes('danger') %}
<div class="alert alert-danger shadow-sm">{{ message }}</div>
{% endfor %}
<div class="card bg-black border-secondary text-light shadow-sm">
<div class="card-body">
<form method="post" action="{{ path('admin_users_create') }}" autocomplete="off">
<input type="hidden" name="_token" value="{{ csrf_token('admin_user_create') }}">
<div class="row g-4">
<div class="col-lg-6">
<label class="form-label" for="user-email">E-Mail</label>
<input id="user-email"
type="email"
name="email"
required
class="form-control bg-dark text-light border-secondary"
autocomplete="off">
</div>
<div class="col-lg-3">
<label class="form-label" for="user-password">Passwort</label>
<input id="user-password"
type="password"
name="password"
required
minlength="8"
class="form-control bg-dark text-light border-secondary"
autocomplete="new-password">
</div>
<div class="col-lg-3">
<label class="form-label" for="user-password-repeat">Passwort wiederholen</label>
<input id="user-password-repeat"
type="password"
name="password_repeat"
required
minlength="8"
class="form-control bg-dark text-light border-secondary"
autocomplete="new-password">
</div>
</div>
<hr class="border-secondary my-4">
<div class="row g-4">
<div class="col-lg-7">
<h2 class="h5 text-info mb-3">Rollen</h2>
<div class="row g-2">
{% for role, label in role_choices %}
<div class="col-md-6">
<label class="form-check bg-dark border border-secondary rounded p-3 h-100">
<input class="form-check-input me-2"
type="checkbox"
name="roles[]"
value="{{ role }}">
<span class="form-check-label">
<strong>{{ label }}</strong><br>
<span class="small text-muted">{{ role }}</span>
</span>
</label>
</div>
{% endfor %}
</div>
</div>
<div class="col-lg-5">
<h2 class="h5 text-info mb-3">Status</h2>
<label class="form-check form-switch bg-dark border border-secondary rounded p-3 ps-5">
<input class="form-check-input" type="checkbox" name="is_active" value="1" checked>
<span class="form-check-label">Benutzer ist aktiv</span>
</label>
<div class="alert alert-info mt-3 mb-0 small">
<strong>Hinweis:</strong> Deaktivierte Benutzer können sich weder im Chat noch im Adminbereich anmelden.
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<a href="{{ path('admin_users_index') }}" class="btn btn-outline-secondary">Abbrechen</a>
<button class="btn btn-outline-info" type="submit">Benutzer erstellen</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,112 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Benutzer bearbeiten{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-person-gear"></i> Benutzer bearbeiten
</h1>
<div class="small text-muted">
{{ managed_user.email }}
</div>
</div>
<a href="{{ path('admin_users_index') }}" class="btn btn-sm btn-outline-secondary">
Zurück zur Liste
</a>
</div>
{% for message in app.flashes('danger') %}
<div class="alert alert-danger shadow-sm">{{ message }}</div>
{% endfor %}
<div class="card bg-black border-secondary text-light shadow-sm">
<div class="card-body">
<form method="post" action="{{ path('admin_users_edit', {id: managed_user.id}) }}" autocomplete="off">
<input type="hidden" name="_token" value="{{ csrf_token('admin_user_edit_' ~ managed_user.id) }}">
<div class="row g-4">
<div class="col-lg-6">
<label class="form-label" for="user-email">E-Mail</label>
<input id="user-email"
type="email"
name="email"
required
value="{{ managed_user.email }}"
class="form-control bg-dark text-light border-secondary"
autocomplete="off">
</div>
<div class="col-lg-3">
<label class="form-label" for="user-password">Neues Passwort</label>
<input id="user-password"
type="password"
name="password"
minlength="8"
class="form-control bg-dark text-light border-secondary"
autocomplete="new-password"
placeholder="leer lassen">
</div>
<div class="col-lg-3">
<label class="form-label" for="user-password-repeat">Passwort wiederholen</label>
<input id="user-password-repeat"
type="password"
name="password_repeat"
minlength="8"
class="form-control bg-dark text-light border-secondary"
autocomplete="new-password"
placeholder="leer lassen">
</div>
</div>
<div class="small text-muted mt-2">
Das Passwort bleibt unverändert, wenn beide Passwortfelder leer sind.
</div>
<hr class="border-secondary my-4">
<div class="row g-4">
<div class="col-lg-7">
<h2 class="h5 text-info mb-3">Rollen</h2>
<div class="row g-2">
{% for role, label in role_choices %}
<div class="col-md-6">
<label class="form-check bg-dark border border-secondary rounded p-3 h-100">
<input class="form-check-input me-2"
type="checkbox"
name="roles[]"
value="{{ role }}"
{{ role in managed_user.roles ? 'checked' : '' }}>
<span class="form-check-label">
<strong>{{ label }}</strong><br>
<span class="small text-muted">{{ role }}</span>
</span>
</label>
</div>
{% endfor %}
</div>
</div>
<div class="col-lg-5">
<h2 class="h5 text-info mb-3">Status</h2>
<label class="form-check form-switch bg-dark border border-secondary rounded p-3 ps-5">
<input class="form-check-input" type="checkbox" name="is_active" value="1" {{ managed_user.active ? 'checked' : '' }}>
<span class="form-check-label">Benutzer ist aktiv</span>
</label>
<div class="alert alert-warning mt-3 mb-0 small">
<strong>Self-Protection:</strong> Du kannst dich nicht selbst deaktivieren und dir nicht selbst die Super-Admin-Rolle entziehen. Der letzte aktive Super-Admin bleibt ebenfalls geschützt.
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<a href="{{ path('admin_users_index') }}" class="btn btn-outline-secondary">Abbrechen</a>
<button class="btn btn-outline-info" type="submit">Änderungen speichern</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,188 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Benutzerverwaltung{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-people-fill"></i> Benutzerverwaltung
</h1>
<div class="small text-muted">
Benutzer anlegen, Rollen zuweisen und Zugänge aktivieren oder deaktivieren.
</div>
</div>
<a href="{{ path('admin_users_create') }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-person-plus-fill"></i> Neuer Benutzer
</a>
</div>
{% for message in app.flashes('success') %}
<div class="alert alert-success shadow-sm">{{ message }}</div>
{% endfor %}
{% for message in app.flashes('danger') %}
<div class="alert alert-danger shadow-sm">{{ message }}</div>
{% endfor %}
{% for message in app.flashes('info') %}
<div class="alert alert-info shadow-sm">{{ message }}</div>
{% endfor %}
<div class="row g-3 mb-4">
<div class="col-md-4 col-xl-3">
<div class="card bg-black border-secondary text-light shadow-sm h-100">
<div class="card-body">
<div class="small text-muted mb-1">Benutzer gesamt</div>
<div class="h3 mb-0">{{ total }}</div>
</div>
</div>
</div>
<div class="col-md-4 col-xl-3">
<div class="card bg-black border-secondary text-light shadow-sm h-100">
<div class="card-body">
<div class="small text-muted mb-1">Aktiv</div>
<div class="h3 mb-0 text-success">{{ active_total }}</div>
</div>
</div>
</div>
<div class="col-md-4 col-xl-3">
<div class="card bg-black border-secondary text-light shadow-sm h-100">
<div class="card-body">
<div class="small text-muted mb-1">Inaktiv</div>
<div class="h3 mb-0 text-warning">{{ inactive_total }}</div>
</div>
</div>
</div>
<div class="col-md-12 col-xl-3">
<div class="card bg-dark border-secondary text-light shadow-sm h-100">
<div class="card-body small">
<strong class="text-info">Schutzregeln aktiv</strong><br>
Der letzte aktive Super-Admin kann nicht deaktiviert oder demotet werden.
</div>
</div>
</div>
</div>
<div class="card bg-dark border-secondary text-light mb-4 shadow-sm">
<div class="card-body">
<form method="get" action="{{ path('admin_users_index') }}" class="row g-3 align-items-end">
<div class="col-lg-4">
<label class="form-label small text-muted" for="user-search">Suche</label>
<input id="user-search"
type="search"
name="q"
value="{{ filters.q }}"
class="form-control bg-black text-light border-secondary"
placeholder="E-Mail suchen">
</div>
<div class="col-lg-3">
<label class="form-label small text-muted" for="user-status">Status</label>
<select id="user-status" name="status" class="form-select bg-black text-light border-secondary">
<option value="all" {{ filters.status == 'all' ? 'selected' : '' }}>Alle</option>
<option value="active" {{ filters.status == 'active' ? 'selected' : '' }}>Aktiv</option>
<option value="inactive" {{ filters.status == 'inactive' ? 'selected' : '' }}>Inaktiv</option>
</select>
</div>
<div class="col-lg-3">
<label class="form-label small text-muted" for="user-role">Rolle</label>
<select id="user-role" name="role" class="form-select bg-black text-light border-secondary">
<option value="all" {{ filters.role == 'all' ? 'selected' : '' }}>Alle Rollen</option>
{% for role, label in role_choices %}
<option value="{{ role }}" {{ filters.role == role ? 'selected' : '' }}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-lg-2 d-flex gap-2">
<button class="btn btn-outline-info w-100" type="submit">
Filtern
</button>
<a class="btn btn-outline-secondary" href="{{ path('admin_users_index') }}" title="Filter zurücksetzen">
<i class="bi bi-x-lg"></i>
</a>
</div>
</form>
</div>
</div>
<div class="card bg-black border-secondary shadow-sm">
<div class="card-body p-0">
<div class="d-flex justify-content-between align-items-center px-3 py-3 border-bottom border-secondary flex-wrap gap-2">
<div>
<strong class="text-info">Benutzer</strong>
<span class="small text-muted ms-2">{{ filtered_total }} von {{ total }} Einträgen</span>
</div>
<div class="small text-muted">Sortiert nach E-Mail.</div>
</div>
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead class="table-secondary text-dark">
<tr>
<th>E-Mail</th>
<th>Status</th>
<th>Rollen</th>
<th>Erstellt</th>
<th>Aktualisiert</th>
<th class="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<div class="fw-semibold text-light">{{ user.email }}</div>
{% if app.user and user.id == app.user.id %}
<span class="badge bg-info text-dark mt-1">Du</span>
{% endif %}
</td>
<td>
{% if user.active %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-warning text-dark">Inaktiv</span>
{% endif %}
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for role, label in role_choices %}
{% if role in user.roles %}
<span class="badge {{ role == 'ROLE_SUPER_ADMIN' ? 'bg-danger' : 'bg-secondary' }}">{{ label }}</span>
{% endif %}
{% endfor %}
</div>
</td>
<td class="small text-muted">
{{ user.createdAt ? user.createdAt|date('d.m.Y H:i') : '—' }}
</td>
<td class="small text-muted">
{{ user.updatedAt ? user.updatedAt|date('d.m.Y H:i') : '—' }}
</td>
<td class="text-end">
<a href="{{ path('admin_users_edit', {id: user.id}) }}" class="btn btn-sm btn-outline-info me-1">
Bearbeiten
</a>
<form method="post"
action="{{ path('admin_users_toggle_active', {id: user.id}) }}"
class="d-inline"
onsubmit="return confirm('{{ user.active ? 'Benutzer wirklich deaktivieren?' : 'Benutzer wirklich aktivieren?' }}');">
<input type="hidden" name="_token" value="{{ csrf_token('admin_user_toggle_active_' ~ user.id) }}">
<button class="btn btn-sm {{ user.active ? 'btn-outline-warning' : 'btn-outline-success' }}">
{{ user.active ? 'Deaktivieren' : 'Aktivieren' }}
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-4 text-muted">
Keine Benutzer gefunden.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}