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

@@ -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;
}

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;
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<User>
*/
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;
}
}

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.');
}
}
}