diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 431cce8..a540b31 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -16,6 +16,7 @@ security: pattern: ^/admin lazy: true provider: app_user_provider + user_checker: App\Security\ActiveUserChecker context: retriex_user_area form_login: @@ -37,6 +38,7 @@ security: pattern: ^/(?:$|chat(?:/|$)|ask-jobs(?:/|$)|ask-sse(?:/|$)|history(?:/|$)|chat-messages/frontend$) lazy: true provider: app_user_provider + user_checker: App\Security\ActiveUserChecker context: retriex_user_area form_login: @@ -68,6 +70,7 @@ security: access_control: - { path: ^/admin/login$, roles: PUBLIC_ACCESS } - { path: ^/admin/logout$, roles: PUBLIC_ACCESS } + - { path: ^/admin/users, roles: ROLE_SUPER_ADMIN } - { path: ^/admin, roles: ROLE_ADMIN_AREA } - { path: ^/chat/login$, roles: PUBLIC_ACCESS } diff --git a/patch_history/RETRIEX_PATCH_93_ADMIN_USER_MANAGEMENT_README.md b/patch_history/RETRIEX_PATCH_93_ADMIN_USER_MANAGEMENT_README.md new file mode 100644 index 0000000..78ed7d5 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_93_ADMIN_USER_MANAGEMENT_README.md @@ -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 diff --git a/src/Command/CreateUserCommand.php b/src/Command/CreateUserCommand.php index 7bf1115..c426896 100644 --- a/src/Command/CreateUserCommand.php +++ b/src/Command/CreateUserCommand.php @@ -1,9 +1,12 @@ getHelper('question'); - // ============================= - // Email - // ============================= $emailQuestion = new Question('E-Mail: '); - $emailQuestion->setValidator(function ($value) { - if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { + $emailQuestion->setValidator(function (mixed $value): string { + $email = strtolower(trim((string) $value)); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new \RuntimeException('Invalid email address.'); } - return strtolower(trim($value)); + + return $email; }); $email = $helper->ask($input, $output, $emailQuestion); - // Prüfen ob User existiert - $existingUser = $this->em - ->getRepository(User::class) - ->findOneBy(['email' => $email]); - - if ($existingUser) { + if ($this->users->findOneByNormalizedEmail($email) instanceof User) { $output->writeln('User already exists.'); + return Command::FAILURE; } - // ============================= - // Passwort - // ============================= $passwordQuestion = new Question('Password: '); $passwordQuestion->setHidden(true); $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('Password must be at least 8 characters.'); + + 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('Passwords do not match.'); + return Command::FAILURE; } - // ============================= - // Rolle auswählen - // ============================= $roleQuestion = new ChoiceQuestion( - 'Select role:', - [ - 'ROLE_SUPER_ADMIN', - 'ROLE_KNOWLEDGE_ADMIN', - 'ROLE_EDITOR', - 'ROLE_ADMIN_AREA', - 'ROLE_CHAT_USER', - ], - 0 + 'Select role(s), comma-separated if needed:', + ApplicationRoles::assignableRoleNames(), + '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('At least one role is required.'); + + return Command::FAILURE; + } + + $activeQuestion = new ConfirmationQuestion('Activate user? [Y/n] ', true); + $isActive = (bool) $helper->ask($input, $output, $activeQuestion); - // ============================= - // User erzeugen - // ============================= $user = new User(); $user->setEmail($email); - $user->setRoles([$role]); - - $hashedPassword = $this->passwordHasher->hashPassword($user, $plainPassword); - $user->setPassword($hashedPassword); + $user->setRoles($roles); + $user->setIsActive($isActive); + $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword)); $this->em->persist($user); $this->em->flush(); $output->writeln('User created successfully.'); $output->writeln('Email: ' . $email); - $output->writeln('Role: ' . $role); + $output->writeln('Roles: ' . implode(', ', $roles)); + $output->writeln('Active: ' . ($isActive ? 'yes' : 'no')); return Command::SUCCESS; } diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php new file mode 100644 index 0000000..6c3b904 --- /dev/null +++ b/src/Controller/Admin/UserController.php @@ -0,0 +1,176 @@ +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 + */ + 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; + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index b29153b..31cd0d3 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -3,6 +3,7 @@ namespace App\Repository; use App\Entity\User; +use App\Security\ApplicationRoles; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -16,28 +17,42 @@ class UserRepository extends ServiceEntityRepository parent::__construct($registry, User::class); } - // /** - // * @return User[] Returns an array of User objects - // */ - // public function findByExampleField($value): array - // { - // return $this->createQueryBuilder('u') - // ->andWhere('u.exampleField = :val') - // ->setParameter('val', $value) - // ->orderBy('u.id', 'ASC') - // ->setMaxResults(10) - // ->getQuery() - // ->getResult() - // ; - // } + /** + * @return list + */ + public function findForAdminList(): array + { + return $this->createQueryBuilder('u') + ->orderBy('u.email', 'ASC') + ->getQuery() + ->getResult(); + } - // public function findOneBySomeField($value): ?User - // { - // return $this->createQueryBuilder('u') - // ->andWhere('u.exampleField = :val') - // ->setParameter('val', $value) - // ->getQuery() - // ->getOneOrNullResult() - // ; - // } + public function findOneByNormalizedEmail(string $email): ?User + { + $normalizedEmail = strtolower(trim($email)); + + if ($normalizedEmail === '') { + return null; + } + + return $this->findOneBy(['email' => $normalizedEmail]); + } + + 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; + } } diff --git a/src/Security/ActiveUserChecker.php b/src/Security/ActiveUserChecker.php new file mode 100644 index 0000000..e67d0e8 --- /dev/null +++ b/src/Security/ActiveUserChecker.php @@ -0,0 +1,28 @@ +isActive()) { + throw new DisabledException('Dieser Benutzer ist deaktiviert.'); + } + } + + public function checkPostAuth(UserInterface $user): void + { + // No post-auth checks required. + } +} diff --git a/src/Security/ActiveUserSessionSubscriber.php b/src/Security/ActiveUserSessionSubscriber.php new file mode 100644 index 0000000..d0df333 --- /dev/null +++ b/src/Security/ActiveUserSessionSubscriber.php @@ -0,0 +1,64 @@ + + */ + 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))); + } +} diff --git a/src/Security/ApplicationRoles.php b/src/Security/ApplicationRoles.php new file mode 100644 index 0000000..aa65a10 --- /dev/null +++ b/src/Security/ApplicationRoles.php @@ -0,0 +1,45 @@ + + */ + 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 + */ + public static function assignableRoleNames(): array + { + return array_keys(self::assignableChoices()); + } + + public static function label(string $role): string + { + return self::assignableChoices()[$role] ?? $role; + } +} diff --git a/src/Service/Admin/UserAdminService.php b/src/Service/Admin/UserAdminService.php new file mode 100644 index 0000000..754c3f4 --- /dev/null +++ b/src/Service/Admin/UserAdminService.php @@ -0,0 +1,279 @@ +, 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 + */ + public function roleChoices(): array + { + return ApplicationRoles::assignableChoices(); + } + + /** + * @param list $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 $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 $roles + * @return list + */ + 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 + */ + private function storedAssignableRoles(User $user): array + { + return array_values(array_intersect(ApplicationRoles::assignableRoleNames(), $user->getRoles())); + } + + /** + * @param list $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.'); + } + } +} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 83e34cd..50eee47 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -64,6 +64,13 @@ Systemübersicht + {% if is_granted('ROLE_SUPER_ADMIN') %} + + Benutzer + + {% endif %} +
diff --git a/templates/admin/user/create.html.twig b/templates/admin/user/create.html.twig new file mode 100644 index 0000000..50a65c6 --- /dev/null +++ b/templates/admin/user/create.html.twig @@ -0,0 +1,106 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}Benutzer anlegen{% endblock %} + +{% block body %} +
+
+

+ Benutzer anlegen +

+
+ Neuer Zugang für Chat, Adminbereich oder Systempflege. +
+
+ + Zurück zur Liste + +
+ + {% for message in app.flashes('danger') %} +
{{ message }}
+ {% endfor %} + +
+
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+

Rollen

+
+ {% for role, label in role_choices %} +
+ +
+ {% endfor %} +
+
+ +
+

Status

+ + +
+ Hinweis: Deaktivierte Benutzer können sich weder im Chat noch im Adminbereich anmelden. +
+
+
+ +
+ Abbrechen + +
+
+
+
+{% endblock %} diff --git a/templates/admin/user/edit.html.twig b/templates/admin/user/edit.html.twig new file mode 100644 index 0000000..fea0194 --- /dev/null +++ b/templates/admin/user/edit.html.twig @@ -0,0 +1,112 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}Benutzer bearbeiten{% endblock %} + +{% block body %} +
+
+

+ Benutzer bearbeiten +

+
+ {{ managed_user.email }} +
+
+ + Zurück zur Liste + +
+ + {% for message in app.flashes('danger') %} +
{{ message }}
+ {% endfor %} + +
+
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ Das Passwort bleibt unverändert, wenn beide Passwortfelder leer sind. +
+ +
+ +
+
+

Rollen

+
+ {% for role, label in role_choices %} +
+ +
+ {% endfor %} +
+
+ +
+

Status

+ + +
+ Self-Protection: Du kannst dich nicht selbst deaktivieren und dir nicht selbst die Super-Admin-Rolle entziehen. Der letzte aktive Super-Admin bleibt ebenfalls geschützt. +
+
+
+ +
+ Abbrechen + +
+
+
+
+{% endblock %} diff --git a/templates/admin/user/index.html.twig b/templates/admin/user/index.html.twig new file mode 100644 index 0000000..24eff22 --- /dev/null +++ b/templates/admin/user/index.html.twig @@ -0,0 +1,188 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}Benutzerverwaltung{% endblock %} + +{% block body %} +
+
+

+ Benutzerverwaltung +

+
+ Benutzer anlegen, Rollen zuweisen und Zugänge aktivieren oder deaktivieren. +
+
+ + + Neuer Benutzer + +
+ + {% for message in app.flashes('success') %} +
{{ message }}
+ {% endfor %} + {% for message in app.flashes('danger') %} +
{{ message }}
+ {% endfor %} + {% for message in app.flashes('info') %} +
{{ message }}
+ {% endfor %} + +
+
+
+
+
Benutzer gesamt
+
{{ total }}
+
+
+
+
+
+
+
Aktiv
+
{{ active_total }}
+
+
+
+
+
+
+
Inaktiv
+
{{ inactive_total }}
+
+
+
+
+
+
+ Schutzregeln aktiv
+ Der letzte aktive Super-Admin kann nicht deaktiviert oder demotet werden. +
+
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+
+ +
+
+
+
+ Benutzer + {{ filtered_total }} von {{ total }} Einträgen +
+
Sortiert nach E-Mail.
+
+ +
+ + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
E-MailStatusRollenErstelltAktualisiertAktionen
+
{{ user.email }}
+ {% if app.user and user.id == app.user.id %} + Du + {% endif %} +
+ {% if user.active %} + Aktiv + {% else %} + Inaktiv + {% endif %} + +
+ {% for role, label in role_choices %} + {% if role in user.roles %} + {{ label }} + {% endif %} + {% endfor %} +
+
+ {{ user.createdAt ? user.createdAt|date('d.m.Y H:i') : '—' }} + + {{ user.updatedAt ? user.updatedAt|date('d.m.Y H:i') : '—' }} + + + Bearbeiten + + +
+ + +
+
+ Keine Benutzer gefunden. +
+
+
+
+{% endblock %}