add user management
This commit is contained in:
@@ -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 }
|
||||
|
||||
148
patch_history/RETRIEX_PATCH_93_ADMIN_USER_MANAGEMENT_README.md
Normal file
148
patch_history/RETRIEX_PATCH_93_ADMIN_USER_MANAGEMENT_README.md
Normal 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
|
||||
@@ -1,9 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\ApplicationRoles;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
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\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\ChoiceQuestion;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
@@ -18,13 +22,13 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
name: 'mto:agent:user:create',
|
||||
description: 'Creates a new application user'
|
||||
)]
|
||||
class CreateUserCommand extends Command
|
||||
final class CreateUserCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private UserPasswordHasherInterface $passwordHasher
|
||||
)
|
||||
{
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UserRepository $users,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -33,76 +37,81 @@ class CreateUserCommand extends Command
|
||||
/** @var QuestionHelper $helper */
|
||||
$helper = $this->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('<error>User already exists.</error>');
|
||||
|
||||
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('<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;
|
||||
}
|
||||
|
||||
// =============================
|
||||
// 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('<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->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('<info>User created successfully.</info>');
|
||||
$output->writeln('Email: ' . $email);
|
||||
$output->writeln('Role: ' . $role);
|
||||
$output->writeln('Roles: ' . implode(', ', $roles));
|
||||
$output->writeln('Active: ' . ($isActive ? 'yes' : 'no'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
176
src/Controller/Admin/UserController.php
Normal file
176
src/Controller/Admin/UserController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
// ;
|
||||
// }
|
||||
|
||||
// public function findOneBySomeField($value): ?User
|
||||
// {
|
||||
// return $this->createQueryBuilder('u')
|
||||
// ->andWhere('u.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->getQuery()
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
/**
|
||||
* @return list<User>
|
||||
*/
|
||||
public function findForAdminList(): array
|
||||
{
|
||||
return $this->createQueryBuilder('u')
|
||||
->orderBy('u.email', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
28
src/Security/ActiveUserChecker.php
Normal file
28
src/Security/ActiveUserChecker.php
Normal 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.
|
||||
}
|
||||
}
|
||||
64
src/Security/ActiveUserSessionSubscriber.php
Normal file
64
src/Security/ActiveUserSessionSubscriber.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
45
src/Security/ApplicationRoles.php
Normal file
45
src/Security/ApplicationRoles.php
Normal 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;
|
||||
}
|
||||
}
|
||||
279
src/Service/Admin/UserAdminService.php
Normal file
279
src/Service/Admin/UserAdminService.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,13 @@
|
||||
<i class="bi bi-hdd-rack"></i> Systemübersicht
|
||||
</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">
|
||||
|
||||
<div class="text-info text-uppercase small mb-2">
|
||||
|
||||
106
templates/admin/user/create.html.twig
Normal file
106
templates/admin/user/create.html.twig
Normal 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 %}
|
||||
112
templates/admin/user/edit.html.twig
Normal file
112
templates/admin/user/edit.html.twig
Normal 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 %}
|
||||
188
templates/admin/user/index.html.twig
Normal file
188
templates/admin/user/index.html.twig
Normal 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 %}
|
||||
Reference in New Issue
Block a user