feat: admin-managed roles and hotel codes
This commit is contained in:
@@ -17,9 +17,6 @@ security:
|
|||||||
roles: [ ROLE_MAILJET_WEBHOOK ]
|
roles: [ ROLE_MAILJET_WEBHOOK ]
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
ROLE_ADMIN:
|
|
||||||
- ROLE_GROUPS_ADMIN
|
|
||||||
- ROLE_HOUSE_MANAGER
|
|
||||||
ROLE_GROUPS_ADMIN:
|
ROLE_GROUPS_ADMIN:
|
||||||
- ROLE_GROUPS_MANAGER
|
- ROLE_GROUPS_MANAGER
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -30,6 +30,31 @@ parameters:
|
|||||||
10321389: 'Reisen-Alert Stubaital'
|
10321389: 'Reisen-Alert Stubaital'
|
||||||
10554990: 'Reisen-Alert Ski & Boarderweek'
|
10554990: 'Reisen-Alert Ski & Boarderweek'
|
||||||
|
|
||||||
|
# Hausleitung hotel codes and their labels, assignable to users in /admin/user.
|
||||||
|
# Mirrors the "Hausleitung {CODE}" CRM selections published by BusProNet.
|
||||||
|
hotel_codes:
|
||||||
|
SSL: 'SSL'
|
||||||
|
SST: 'SST'
|
||||||
|
MVK: 'MVK'
|
||||||
|
ASB: 'ASB'
|
||||||
|
LPJ: 'LPJ'
|
||||||
|
DKS: 'DKS'
|
||||||
|
DGS: 'DGS'
|
||||||
|
DPW: 'DPW'
|
||||||
|
DKI: 'DKI'
|
||||||
|
DWW: 'DWW'
|
||||||
|
PMV: 'PMV'
|
||||||
|
ASG: 'ASG'
|
||||||
|
ASC: 'ASC'
|
||||||
|
SBW: 'SBW'
|
||||||
|
UCH: 'UCH'
|
||||||
|
SZO: 'SZO'
|
||||||
|
PCJ: 'PCJ'
|
||||||
|
KHH: 'KHH'
|
||||||
|
SVS: 'SVS'
|
||||||
|
SHM: 'SHM'
|
||||||
|
AGR: 'AGR'
|
||||||
|
|
||||||
# MailJet contact metadata names for name synchronization
|
# MailJet contact metadata names for name synchronization
|
||||||
mailjet_contact_metadata_fields:
|
mailjet_contact_metadata_fields:
|
||||||
firstName: 'vorname'
|
firstName: 'vorname'
|
||||||
@@ -253,6 +278,10 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$mailjetLists: '%mailjet_lists%'
|
$mailjetLists: '%mailjet_lists%'
|
||||||
|
|
||||||
|
App\Form\Admin\UserType:
|
||||||
|
arguments:
|
||||||
|
$hotelCodes: '%hotel_codes%'
|
||||||
|
|
||||||
App\Service\DomainConfigProvider:
|
App\Service\DomainConfigProvider:
|
||||||
arguments:
|
arguments:
|
||||||
$domainConfig: '%domain_config%'
|
$domainConfig: '%domain_config%'
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\User;
|
||||||
|
|
||||||
|
use App\Controller\Traits\ReturnUrlTrait;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\Admin\UserType;
|
||||||
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
|
class EditController extends AbstractController
|
||||||
|
{
|
||||||
|
use ReturnUrlTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/user/{id}/edit', name: 'app_admin_user_edit')]
|
||||||
|
public function index(User $user, Request $request): Response
|
||||||
|
{
|
||||||
|
$previousRoles = $user->getRoles();
|
||||||
|
$previousHotelCodes = $user->getHotelCodes();
|
||||||
|
|
||||||
|
$form = $this->createForm(UserType::class, $user, ['hx_post' => $request->getRequestUri()]);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
if ($this->locksOutSelf($user)) {
|
||||||
|
$form->get('roles')->addError(new FormError('Du kannst dir die Rolle Administration nicht selbst entziehen.'));
|
||||||
|
|
||||||
|
return $this->render('admin/user/modal_edit.html.twig', [
|
||||||
|
'user' => $user,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Der Benutzeraccount wurde aktualisiert');
|
||||||
|
|
||||||
|
$this->logger->info('Updated user permissions', [
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'previousRoles' => $previousRoles,
|
||||||
|
'roles' => $user->getRoles(),
|
||||||
|
'previousHotelCodes' => $previousHotelCodes,
|
||||||
|
'hotelCodes' => $user->getHotelCodes(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new HxRedirectResponse($this->getReturnUrl($request, 'app_admin_user'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/user/modal_edit.html.twig', [
|
||||||
|
'user' => $user,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function locksOutSelf(User $user): bool
|
||||||
|
{
|
||||||
|
return $user === $this->getUser() && false === \in_array(Role::ADMIN, $user->getRoles(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ class FilterController extends AbstractController
|
|||||||
UserFilterType::class,
|
UserFilterType::class,
|
||||||
new UserFilterDto(),
|
new UserFilterDto(),
|
||||||
'app_admin_user',
|
'app_admin_user',
|
||||||
['roles' => UserFilterType::ROLES],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->render('admin/_modal_filter.html.twig', [
|
return $this->render('admin/_modal_filter.html.twig', [
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ class IndexController extends AbstractController
|
|||||||
UserFilterType::class,
|
UserFilterType::class,
|
||||||
$filter,
|
$filter,
|
||||||
'app_admin_user',
|
'app_admin_user',
|
||||||
['roles' => UserFilterType::ROLES],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$pagination = $this->paginator->paginate(
|
$pagination = $this->paginator->paginate(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Form\Admin\Filter;
|
namespace App\Form\Admin\Filter;
|
||||||
|
|
||||||
use App\Form\Model\Filter\UserFilterDto;
|
use App\Form\Model\Filter\UserFilterDto;
|
||||||
|
use App\Security\Role;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
@@ -13,28 +14,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
*/
|
*/
|
||||||
class UserFilterType extends AbstractListFilterType
|
class UserFilterType extends AbstractListFilterType
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* The roles that are actually assigned to accounts. ROLE_USER is left out because every
|
|
||||||
* account has it implicitly and filtering by it would match everything.
|
|
||||||
*
|
|
||||||
* @var string[]
|
|
||||||
*/
|
|
||||||
public const ROLES = [
|
|
||||||
'ROLE_ADMIN',
|
|
||||||
'ROLE_MANAGER',
|
|
||||||
'ROLE_TEAMER',
|
|
||||||
'ROLE_CUSTOMER',
|
|
||||||
'ROLE_HOUSE_MANAGER',
|
|
||||||
'ROLE_GROUPS_ADMIN',
|
|
||||||
'ROLE_GROUPS_MANAGER',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected function filterFields(array $options): array
|
protected function filterFields(array $options): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'role' => [ChoiceType::class, [
|
'role' => [ChoiceType::class, [
|
||||||
'label' => 'Rolle',
|
'label' => 'Rolle',
|
||||||
'choices' => array_combine($options['roles'], $options['roles']),
|
'choices' => array_flip(Role::labels()),
|
||||||
'placeholder' => 'alle',
|
'placeholder' => 'alle',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
]],
|
]],
|
||||||
@@ -47,9 +32,7 @@ class UserFilterType extends AbstractListFilterType
|
|||||||
|
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'data_class' => UserFilterDto::class,
|
'data_class' => UserFilterDto::class,
|
||||||
'roles' => [],
|
|
||||||
]);
|
]);
|
||||||
$resolver->setAllowedTypes('roles', 'string[]');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function searchPlaceholder(): string
|
protected function searchPlaceholder(): string
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Admin;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets an administrator assign roles and hotel codes to an existing account.
|
||||||
|
*
|
||||||
|
* This is the only way those two get changed after the account was created — BpnAuthenticator
|
||||||
|
* imports them once and never touches them again.
|
||||||
|
*
|
||||||
|
* @extends AbstractType<User>
|
||||||
|
*/
|
||||||
|
class UserType extends AbstractType
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $hotelCodes code => label
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly array $hotelCodes = [])
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$user = $builder->getData();
|
||||||
|
|
||||||
|
$builder
|
||||||
|
->add('roles', ChoiceType::class, [
|
||||||
|
'label' => 'Rollen',
|
||||||
|
'choices' => array_flip(Role::labels()),
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'required' => false,
|
||||||
|
// User::getRoles() prepends the implicit ROLE_USER, which must not be written back.
|
||||||
|
'getter' => static fn (User $user): array => array_values(array_diff($user->getRoles(), ['ROLE_USER'])),
|
||||||
|
'setter' => static function (User $user, array $roles): void {
|
||||||
|
$user->setRoles(array_values(array_unique($roles)));
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->add('hotelCodes', ChoiceType::class, [
|
||||||
|
'label' => 'Häuser',
|
||||||
|
'choices' => $this->hotelCodeChoices($user),
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'required' => false,
|
||||||
|
'setter' => static function (User $user, array $hotelCodes): void {
|
||||||
|
$user->setHotelCodes(array_values(array_unique($hotelCodes)));
|
||||||
|
},
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codes already stored on the account are always offered, even when they are missing from
|
||||||
|
* the configured catalog — otherwise saving the form would silently drop them.
|
||||||
|
*
|
||||||
|
* @return array<string, string> label => code
|
||||||
|
*/
|
||||||
|
private function hotelCodeChoices(?User $user): array
|
||||||
|
{
|
||||||
|
$codes = $this->hotelCodes;
|
||||||
|
|
||||||
|
foreach ($user?->getHotelCodes() ?? [] as $code) {
|
||||||
|
$codes[$code] ??= $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($codes);
|
||||||
|
|
||||||
|
return array_flip($codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => User::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Form\Model\Filter;
|
namespace App\Form\Model\Filter;
|
||||||
|
|
||||||
use App\Model\ListFilterChip;
|
use App\Model\ListFilterChip;
|
||||||
|
use App\Security\Role;
|
||||||
|
|
||||||
class UserFilterDto extends AbstractListFilterDto
|
class UserFilterDto extends AbstractListFilterDto
|
||||||
{
|
{
|
||||||
@@ -15,7 +16,7 @@ class UserFilterDto extends AbstractListFilterDto
|
|||||||
$chips = parent::activeFilters();
|
$chips = parent::activeFilters();
|
||||||
|
|
||||||
if (null !== $this->role && '' !== $this->role) {
|
if (null !== $this->role && '' !== $this->role) {
|
||||||
$chips[] = new ListFilterChip('Rolle', $this->role, ['role']);
|
$chips[] = new ListFilterChip('Rolle', Role::labels()[$this->role] ?? $this->role, ['role']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $chips;
|
return $chips;
|
||||||
|
|||||||
@@ -31,9 +31,13 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
|||||||
/**
|
/**
|
||||||
* Authenticates users against the BPN API.
|
* Authenticates users against the BPN API.
|
||||||
*
|
*
|
||||||
* Validates credentials via BPN's getPersonalData endpoint, creates or updates
|
* Validates credentials via BPN's getPersonalData endpoint and creates or updates
|
||||||
* local User entities, and retrieves CRM attributes (roles, hotel codes) for
|
* local User entities. Passwords are stored encrypted with RSA for subsequent API calls.
|
||||||
* authorization. Passwords are stored encrypted with RSA for subsequent API calls.
|
*
|
||||||
|
* CRM attributes (roles, hotel codes) seed a *new* account only: BusPro backend users can
|
||||||
|
* edit their own CRM selections, so taking roles over on every login would let anybody grant
|
||||||
|
* themselves Role::PRIVILEGED here. Privileged roles are never imported at all, and from the
|
||||||
|
* second login on both roles and hotel codes are managed by an administrator in /admin/user.
|
||||||
*/
|
*/
|
||||||
class BpnAuthenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface
|
class BpnAuthenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface
|
||||||
{
|
{
|
||||||
@@ -96,15 +100,17 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
throw new CustomUserMessageAuthenticationException($message);
|
throw new CustomUserMessageAuthenticationException($message);
|
||||||
}
|
}
|
||||||
|
|
||||||
$roles = $crmAttributes->roles;
|
|
||||||
$hotelCodes = $crmAttributes->hotelCodes;
|
|
||||||
|
|
||||||
$encryptedPassword = $this->crypt->encrypt($password);
|
$encryptedPassword = $this->crypt->encrypt($password);
|
||||||
|
|
||||||
$userRepository = $this->entityManager->getRepository(User::class);
|
$userRepository = $this->entityManager->getRepository(User::class);
|
||||||
|
|
||||||
if (null === $user = $userRepository->findOneBy(['email' => $email])) {
|
if (null === $user = $userRepository->findOneBy(['email' => $email])) {
|
||||||
$user = new User($email);
|
$user = new User($email);
|
||||||
|
$user
|
||||||
|
->setRoles($this->importableRoles($email, $crmAttributes->roles))
|
||||||
|
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
|
||||||
|
;
|
||||||
|
|
||||||
$this->entityManager->persist($user);
|
$this->entityManager->persist($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +118,6 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
->setPassword($encryptedPassword)
|
->setPassword($encryptedPassword)
|
||||||
->setPersonId($personalData->personId)
|
->setPersonId($personalData->personId)
|
||||||
->setAddressId($personalData->addressId)
|
->setAddressId($personalData->addressId)
|
||||||
->setRoles($roles)
|
|
||||||
->setHotelCodes($hotelCodes)
|
|
||||||
->setLastLoginAt(new \DateTimeImmutable())
|
->setLastLoginAt(new \DateTimeImmutable())
|
||||||
->setProfileComplete($this->completenessChecker->isComplete($personalData))
|
->setProfileComplete($this->completenessChecker->isComplete($personalData))
|
||||||
;
|
;
|
||||||
@@ -123,6 +127,28 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $crmRoles
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function importableRoles(string $email, array $crmRoles): array
|
||||||
|
{
|
||||||
|
$roles = Role::filterImportable($crmRoles);
|
||||||
|
$dropped = array_values(array_intersect($crmRoles, Role::PRIVILEGED));
|
||||||
|
|
||||||
|
if ([] !== $dropped) {
|
||||||
|
// Somebody holds a privileged CRM selection in BusPro. We do not honour it, but it
|
||||||
|
// should stay visible: it either needs to be revoked there or granted in /admin/user.
|
||||||
|
$this->authLogger->warning('Ignored privileged roles from BPN CRM attributes', [
|
||||||
|
'email' => $email,
|
||||||
|
'roles' => $dropped,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $roles;
|
||||||
|
}
|
||||||
|
|
||||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
{
|
{
|
||||||
$this->authLogger->info('Login', [
|
$this->authLogger->info('Login', [
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles this application knows about.
|
||||||
|
*
|
||||||
|
* Roles are stored as plain strings in the User::$roles JSON column and consumed as strings
|
||||||
|
* by #[IsGranted], the voters and security.yaml's role_hierarchy, so they are constants
|
||||||
|
* rather than an enum.
|
||||||
|
*/
|
||||||
|
final class Role
|
||||||
|
{
|
||||||
|
public const ADMIN = 'ROLE_ADMIN';
|
||||||
|
public const MANAGER = 'ROLE_MANAGER';
|
||||||
|
public const TEAMER = 'ROLE_TEAMER';
|
||||||
|
public const CUSTOMER = 'ROLE_CUSTOMER';
|
||||||
|
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
|
||||||
|
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
|
||||||
|
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles that are actually assigned to accounts. ROLE_USER is left out because every
|
||||||
|
* account has it implicitly (see User::getRoles()) and it is never stored.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
public const ALL = [
|
||||||
|
self::ADMIN,
|
||||||
|
self::MANAGER,
|
||||||
|
self::TEAMER,
|
||||||
|
self::CUSTOMER,
|
||||||
|
self::HOUSE_MANAGER,
|
||||||
|
self::GROUPS_ADMIN,
|
||||||
|
self::GROUPS_MANAGER,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles that are never taken over from BusProNet. Many people can edit CRM selections in
|
||||||
|
* the BusPro backend, so these are granted by an administrator in /admin/user only.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
public const PRIVILEGED = [
|
||||||
|
self::ADMIN,
|
||||||
|
self::GROUPS_ADMIN,
|
||||||
|
self::GROUPS_MANAGER,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces the roles derived from BPN CRM attributes to the ones we accept from there.
|
||||||
|
*
|
||||||
|
* Falls back to ROLE_CUSTOMER the way CrmAttributesResponseParser does, so an account
|
||||||
|
* whose only selection was a privileged one does not end up without any role.
|
||||||
|
*
|
||||||
|
* @param string[] $roles
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function filterImportable(array $roles): array
|
||||||
|
{
|
||||||
|
$importable = array_values(array_unique(array_diff($roles, self::PRIVILEGED)));
|
||||||
|
|
||||||
|
return [] === $importable ? [self::CUSTOMER] : $importable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string> role => label
|
||||||
|
*/
|
||||||
|
public static function labels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::ADMIN => 'Administration',
|
||||||
|
self::MANAGER => 'Manager:in',
|
||||||
|
self::TEAMER => 'Teamer:in',
|
||||||
|
self::CUSTOMER => 'Kund:in',
|
||||||
|
self::HOUSE_MANAGER => 'Hausleitung',
|
||||||
|
self::GROUPS_ADMIN => 'Preisrechner Admin',
|
||||||
|
self::GROUPS_MANAGER => 'Preisrechner',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ class AppExtension extends AbstractExtension
|
|||||||
new TwigFilter('map_status', [AppRuntime::class, 'mapStatus']),
|
new TwigFilter('map_status', [AppRuntime::class, 'mapStatus']),
|
||||||
new TwigFilter('map_country', [AppRuntime::class, 'mapCountry']),
|
new TwigFilter('map_country', [AppRuntime::class, 'mapCountry']),
|
||||||
new TwigFilter('map_nationality', [AppRuntime::class, 'mapNationality']),
|
new TwigFilter('map_nationality', [AppRuntime::class, 'mapNationality']),
|
||||||
|
new TwigFilter('map_roles', [AppRuntime::class, 'mapRoles']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Form\Service\Condition\TravelStartCutoffReachedCondition;
|
|||||||
use App\Form\Service\CreateFieldStateProvider;
|
use App\Form\Service\CreateFieldStateProvider;
|
||||||
use App\Form\Service\EditFieldStateProvider;
|
use App\Form\Service\EditFieldStateProvider;
|
||||||
use App\Model\DomainConfig;
|
use App\Model\DomainConfig;
|
||||||
|
use App\Security\Role;
|
||||||
use App\Service\ParticipantEligibilityChecker;
|
use App\Service\ParticipantEligibilityChecker;
|
||||||
use Symfony\Component\Form\FormView;
|
use Symfony\Component\Form\FormView;
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
@@ -124,6 +125,33 @@ class AppRuntime implements RuntimeExtensionInterface
|
|||||||
return $this->countryDataProvider->get($nationality)?->nationality;
|
return $this->countryDataProvider->get($nationality)?->nationality;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the roles stored on a User into the labels the edit form uses.
|
||||||
|
*
|
||||||
|
* ROLE_USER is dropped because every account holds it implicitly; a role without a label
|
||||||
|
* is passed through unchanged so it stays visible instead of silently disappearing.
|
||||||
|
*
|
||||||
|
* @param string[] $roles
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function mapRoles(array $roles): array
|
||||||
|
{
|
||||||
|
$labels = Role::labels();
|
||||||
|
|
||||||
|
$mapped = [];
|
||||||
|
|
||||||
|
foreach ($roles as $role) {
|
||||||
|
if ('ROLE_USER' === $role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapped[] = $labels[$role] ?? $role;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mapped;
|
||||||
|
}
|
||||||
|
|
||||||
public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool
|
public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool
|
||||||
{
|
{
|
||||||
return $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex);
|
return $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex);
|
||||||
|
|||||||
@@ -22,9 +22,16 @@
|
|||||||
<th>
|
<th>
|
||||||
{{ knp_pagination_sortable(pagination, 'BusPro Personen Id', 'user.personId') }}
|
{{ knp_pagination_sortable(pagination, 'BusPro Personen Id', 'user.personId') }}
|
||||||
</th>
|
</th>
|
||||||
|
<th>
|
||||||
|
Rollen
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Häuser
|
||||||
|
</th>
|
||||||
<th>
|
<th>
|
||||||
{{ knp_pagination_sortable(pagination, 'Letzter Login', 'user.lastLoginAt') }}
|
{{ knp_pagination_sortable(pagination, 'Letzter Login', 'user.lastLoginAt') }}
|
||||||
</th>
|
</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -39,13 +46,26 @@
|
|||||||
<td>
|
<td>
|
||||||
{{ user.personId | default('-') }}
|
{{ user.personId | default('-') }}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ user.roles | map_roles | join(', ') | default('-') }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ user.hotelCodes | join(', ') | default('-') }}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ user.lastLoginAt | date('d.m.Y, H:i') }}
|
{{ user.lastLoginAt | date('d.m.Y, H:i') }}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<twig:dropdown>
|
||||||
|
<twig:dropdown:hxbutton url="{{ path('app_admin_user_edit', { 'id': user.id, 'r': return_url() }) }}">
|
||||||
|
Berechtigungen bearbeiten
|
||||||
|
</twig:dropdown:hxbutton>
|
||||||
|
</twig:dropdown>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4">
|
<td colspan="7">
|
||||||
{{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}
|
{{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends 'htmx_modal_admin.html.twig' %}
|
||||||
|
{% form_theme form 'forms_admin.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Berechtigungen
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="pb-4 text-sm text-gray-500">
|
||||||
|
{{ user.email }}
|
||||||
|
</div>
|
||||||
|
{{ form_start(form) }}
|
||||||
|
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
||||||
|
{{ form_row(form.roles) }}
|
||||||
|
{{ form_row(form.hotelCodes) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end pt-8">
|
||||||
|
<button type="submit" class="button button--primary button--small">
|
||||||
|
speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{ form_rest(form) }}
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Form\Admin;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\Admin\UserType;
|
||||||
|
use App\Security\Role;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\Form\Forms;
|
||||||
|
|
||||||
|
class UserTypeTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testStoredRolesArePreselectedWithoutTheImplicitRoleUser(): void
|
||||||
|
{
|
||||||
|
$user = (new User('[email protected]'))->setRoles([Role::TEAMER]);
|
||||||
|
|
||||||
|
self::assertSame([Role::TEAMER], $this->createForm($user)->get('roles')->getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubmittingRolesDoesNotStoreTheImplicitRoleUser(): void
|
||||||
|
{
|
||||||
|
$user = (new User('[email protected]'))->setRoles([Role::TEAMER]);
|
||||||
|
|
||||||
|
$form = $this->createForm($user);
|
||||||
|
$form->submit(['roles' => [Role::TEAMER, Role::GROUPS_MANAGER], 'hotelCodes' => []]);
|
||||||
|
|
||||||
|
self::assertTrue($form->isSynchronized());
|
||||||
|
// getRoles() prepends ROLE_USER; it must not have been persisted a second time.
|
||||||
|
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClearingEveryCheckboxEmptiesTheAssignment(): void
|
||||||
|
{
|
||||||
|
$user = (new User('[email protected]'))
|
||||||
|
->setRoles([Role::TEAMER])
|
||||||
|
->setHotelCodes(['SSL'])
|
||||||
|
;
|
||||||
|
|
||||||
|
$form = $this->createForm($user);
|
||||||
|
$form->submit([]);
|
||||||
|
|
||||||
|
self::assertTrue($form->isSynchronized());
|
||||||
|
self::assertSame(['ROLE_USER'], $user->getRoles());
|
||||||
|
self::assertSame([], $user->getHotelCodes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHotelCodeMissingFromTheCatalogSurvivesAnEdit(): void
|
||||||
|
{
|
||||||
|
$user = (new User('[email protected]'))->setHotelCodes(['SSL', 'XYZ']);
|
||||||
|
|
||||||
|
$form = $this->createForm($user);
|
||||||
|
$form->submit(['roles' => [], 'hotelCodes' => ['SSL', 'XYZ']]);
|
||||||
|
|
||||||
|
self::assertTrue($form->isSynchronized());
|
||||||
|
self::assertSame(['SSL', 'XYZ'], $user->getHotelCodes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHotelCodesAreOfferedAlphabetically(): void
|
||||||
|
{
|
||||||
|
$user = (new User('[email protected]'))->setHotelCodes(['ASB']);
|
||||||
|
|
||||||
|
$choices = $this->createForm($user)->get('hotelCodes')->getConfig()->getOption('choices');
|
||||||
|
|
||||||
|
self::assertSame(['ASB', 'DKS', 'SSL'], array_values($choices));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return FormInterface<User>
|
||||||
|
*/
|
||||||
|
private function createForm(User $user): FormInterface
|
||||||
|
{
|
||||||
|
return Forms::createFormFactoryBuilder()
|
||||||
|
->addType(new UserType(['SSL' => 'SSL', 'DKS' => 'DKS']))
|
||||||
|
->getFormFactory()
|
||||||
|
->create(UserType::class, $user)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Form\Model\Filter;
|
||||||
|
|
||||||
|
use App\Form\Model\Filter\UserFilterDto;
|
||||||
|
use App\Security\Role;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class UserFilterDtoTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTheRoleChipShowsTheLabelRatherThanTheRoleName(): void
|
||||||
|
{
|
||||||
|
$filter = new UserFilterDto();
|
||||||
|
$filter->role = Role::GROUPS_ADMIN;
|
||||||
|
|
||||||
|
$chip = $filter->activeFilters()[0];
|
||||||
|
|
||||||
|
self::assertSame('Rolle', $chip->label);
|
||||||
|
self::assertSame('Preisrechner Admin', $chip->value);
|
||||||
|
self::assertSame(['role'], $chip->removeKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnknownRoleIsShownVerbatim(): void
|
||||||
|
{
|
||||||
|
$filter = new UserFilterDto();
|
||||||
|
$filter->role = 'ROLE_LEGACY';
|
||||||
|
|
||||||
|
self::assertSame('ROLE_LEGACY', $filter->activeFilters()[0]->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoRoleMeansNoChip(): void
|
||||||
|
{
|
||||||
|
self::assertSame([], (new UserFilterDto())->activeFilters());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Security;
|
||||||
|
|
||||||
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Model\CrmAttributes;
|
||||||
|
use App\BusProNet\Model\PersonalData;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Security\BpnAuthenticator;
|
||||||
|
use App\Security\Crypt;
|
||||||
|
use App\Security\Role;
|
||||||
|
use App\Service\ProfileCompletenessChecker;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\EntityRepository;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers who may grant roles: BusPro backend users can edit their own CRM selections, so the
|
||||||
|
* import must not be a channel for privilege escalation.
|
||||||
|
*/
|
||||||
|
class BpnAuthenticatorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testNewAccountIsSeededWithTheImportableRolesOnly(): void
|
||||||
|
{
|
||||||
|
$persisted = null;
|
||||||
|
$authenticator = $this->authenticator(
|
||||||
|
$this->crmAttributes([Role::ADMIN, Role::TEAMER, Role::GROUPS_ADMIN], ['SSL', 'SSL']),
|
||||||
|
null,
|
||||||
|
$persisted,
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = $this->loadUser($authenticator);
|
||||||
|
|
||||||
|
self::assertSame($persisted, $user);
|
||||||
|
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
|
||||||
|
self::assertSame(['SSL'], $user->getHotelCodes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExistingAccountKeepsTheRolesAnAdministratorAssigned(): void
|
||||||
|
{
|
||||||
|
$existing = (new User('[email protected]'))
|
||||||
|
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
|
||||||
|
->setHotelCodes(['DKS'])
|
||||||
|
;
|
||||||
|
|
||||||
|
$persisted = null;
|
||||||
|
$authenticator = $this->authenticator(
|
||||||
|
$this->crmAttributes([Role::ADMIN, Role::CUSTOMER], ['SSL']),
|
||||||
|
$existing,
|
||||||
|
$persisted,
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = $this->loadUser($authenticator);
|
||||||
|
|
||||||
|
self::assertNull($persisted, 'an existing account must not be persisted again');
|
||||||
|
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles());
|
||||||
|
self::assertSame(['DKS'], $user->getHotelCodes());
|
||||||
|
self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
* @param string[] $hotelCodes
|
||||||
|
*/
|
||||||
|
private function crmAttributes(array $roles, array $hotelCodes): CrmAttributes
|
||||||
|
{
|
||||||
|
$attributes = new CrmAttributes();
|
||||||
|
$attributes->roles = $roles;
|
||||||
|
$attributes->hotelCodes = $hotelCodes;
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authenticator(CrmAttributes $crmAttributes, ?User $existing, ?User &$persisted): BpnAuthenticator
|
||||||
|
{
|
||||||
|
$personalData = new PersonalData();
|
||||||
|
$personalData->personId = 42;
|
||||||
|
$personalData->addressId = 4711;
|
||||||
|
|
||||||
|
$apiClient = $this->createMock(ApiClient::class);
|
||||||
|
$apiClient->method('getPersonalData')->willReturn($personalData);
|
||||||
|
$apiClient->method('getCrmAttributes')->willReturn($crmAttributes);
|
||||||
|
|
||||||
|
$repository = $this->createMock(EntityRepository::class);
|
||||||
|
$repository->method('findOneBy')->willReturn($existing);
|
||||||
|
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager->method('getRepository')->willReturn($repository);
|
||||||
|
$entityManager
|
||||||
|
->method('persist')
|
||||||
|
->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||||
|
$persisted = $entity;
|
||||||
|
})
|
||||||
|
;
|
||||||
|
|
||||||
|
$crypt = $this->createMock(Crypt::class);
|
||||||
|
$crypt->method('encrypt')->willReturn('encrypted');
|
||||||
|
|
||||||
|
$completenessChecker = $this->createMock(ProfileCompletenessChecker::class);
|
||||||
|
$completenessChecker->method('isComplete')->willReturn(true);
|
||||||
|
|
||||||
|
return new BpnAuthenticator(
|
||||||
|
$this->createMock(UrlGeneratorInterface::class),
|
||||||
|
$apiClient,
|
||||||
|
$entityManager,
|
||||||
|
$crypt,
|
||||||
|
$completenessChecker,
|
||||||
|
$this->createMock(LoggerInterface::class),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadUser(BpnAuthenticator $authenticator): User
|
||||||
|
{
|
||||||
|
$request = new Request();
|
||||||
|
$request->request->set('_username', '[email protected]');
|
||||||
|
$request->request->set('_password', 'secret');
|
||||||
|
|
||||||
|
$badge = $authenticator->authenticate($request)->getBadge(UserBadge::class);
|
||||||
|
self::assertInstanceOf(UserBadge::class, $badge);
|
||||||
|
|
||||||
|
$user = $badge->getUser();
|
||||||
|
self::assertInstanceOf(User::class, $user);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Security;
|
||||||
|
|
||||||
|
use App\Security\Role;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class RoleTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testPrivilegedRolesAreNeverImported(): void
|
||||||
|
{
|
||||||
|
$roles = Role::filterImportable([
|
||||||
|
Role::TEAMER,
|
||||||
|
Role::ADMIN,
|
||||||
|
Role::GROUPS_ADMIN,
|
||||||
|
Role::GROUPS_MANAGER,
|
||||||
|
Role::HOUSE_MANAGER,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], $roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResultIsADedupedList(): void
|
||||||
|
{
|
||||||
|
// CrmAttributesResponseParser applies array_unique(), which preserves keys — a
|
||||||
|
// non-list would be persisted as a JSON object instead of an array.
|
||||||
|
$roles = Role::filterImportable([0 => Role::ADMIN, 2 => Role::TEAMER, 5 => Role::TEAMER]);
|
||||||
|
|
||||||
|
self::assertSame([Role::TEAMER], $roles);
|
||||||
|
self::assertSame(array_keys($roles), range(0, \count($roles) - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAccountWithOnlyPrivilegedRolesFallsBackToCustomer(): void
|
||||||
|
{
|
||||||
|
self::assertSame([Role::CUSTOMER], Role::filterImportable([Role::ADMIN]));
|
||||||
|
self::assertSame([Role::CUSTOMER], Role::filterImportable([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEveryRoleHasALabel(): void
|
||||||
|
{
|
||||||
|
self::assertSame(Role::ALL, array_keys(Role::labels()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Twig;
|
||||||
|
|
||||||
|
use App\Security\Role;
|
||||||
|
use App\Twig\AppRuntime;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class AppRuntimeMapRolesTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testStoredRolesAreLabelledLikeInTheEditForm(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
['Teamer:in', 'Preisrechner'],
|
||||||
|
$this->runtime()->mapRoles([Role::TEAMER, Role::GROUPS_MANAGER]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testImplicitRoleUserIsNotListed(): void
|
||||||
|
{
|
||||||
|
self::assertSame(['Administration'], $this->runtime()->mapRoles(['ROLE_USER', Role::ADMIN]));
|
||||||
|
self::assertSame([], $this->runtime()->mapRoles(['ROLE_USER']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownRoleStaysVisible(): void
|
||||||
|
{
|
||||||
|
self::assertSame(['ROLE_LEGACY'], $this->runtime()->mapRoles(['ROLE_LEGACY']));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runtime(): AppRuntime
|
||||||
|
{
|
||||||
|
return (new \ReflectionClass(AppRuntime::class))->newInstanceWithoutConstructor();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user