mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-06-12 13:24:25 +00:00
Merge branch 'signature-app/wp-706-no-forward-unless-signed' into 'signature-app-master'
Block transition if there is a pending signature for the entityWorkflow See merge request Chill-Projet/chill-bundles!736
This commit is contained in:
commit
7b06c80c2a
@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\MainBundle\Security\Authorization;
|
namespace Chill\MainBundle\Security\Authorization;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
|
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
|
||||||
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
|
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
|
||||||
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
|
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
|
||||||
@ -55,13 +56,13 @@ final class EntityWorkflowTransitionVoter extends Voter implements ProvideRoleHi
|
|||||||
|
|
||||||
protected function supports(string $attribute, $subject): bool
|
protected function supports(string $attribute, $subject): bool
|
||||||
{
|
{
|
||||||
return self::APPLY_ALL_TRANSITIONS === $attribute && $subject instanceof EntityWorkflowStep;
|
return self::APPLY_ALL_TRANSITIONS === $attribute && ($subject instanceof EntityWorkflowStep || $subject instanceof EntityWorkflow);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||||
{
|
{
|
||||||
/** @var EntityWorkflowStep $subject */
|
/** @var EntityWorkflowStep|EntityWorkflow $subject */
|
||||||
$entityWorkflow = $subject->getEntityWorkflow();
|
$entityWorkflow = $subject instanceof EntityWorkflowStep ? $subject->getEntityWorkflow() : $subject;
|
||||||
|
|
||||||
if (!$this->accessDecisionManager->decide($token, [EntityWorkflowVoter::SEE], $entityWorkflow)) {
|
if (!$this->accessDecisionManager->decide($token, [EntityWorkflowVoter::SEE], $entityWorkflow)) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -18,7 +18,14 @@ final readonly class CenterResolverManager implements CenterResolverManagerInter
|
|||||||
/**
|
/**
|
||||||
* @param \Chill\MainBundle\Security\Resolver\CenterResolverInterface[] $resolvers
|
* @param \Chill\MainBundle\Security\Resolver\CenterResolverInterface[] $resolvers
|
||||||
*/
|
*/
|
||||||
public function __construct(private iterable $resolvers = []) {}
|
public function __construct(private iterable $resolvers = [])
|
||||||
|
{
|
||||||
|
foreach ($resolvers as $resolver) {
|
||||||
|
if ($resolver instanceof ManagerAwareCenterResolverInterface) {
|
||||||
|
$resolver->setResolverManager($this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function resolveCenters($entity, ?array $options = []): array
|
public function resolveCenters($entity, ?array $options = []): array
|
||||||
{
|
{
|
||||||
|
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\MainBundle\Security\Resolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center resolver which need the CenterResolverManager.
|
||||||
|
*
|
||||||
|
* Appended to @see{CenterResolverInterface} which needs the @{CenterResolverManager} to resolve the center
|
||||||
|
*/
|
||||||
|
interface ManagerAwareCenterResolverInterface
|
||||||
|
{
|
||||||
|
public function setResolverManager(CenterResolverManagerInterface $manager): void;
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\MainBundle\Security\Resolver;
|
||||||
|
|
||||||
|
trait ManagerAwareCenterResolverTrait
|
||||||
|
{
|
||||||
|
protected CenterResolverManagerInterface $centerResolverManager;
|
||||||
|
|
||||||
|
public function setResolverManager(CenterResolverManagerInterface $manager): void
|
||||||
|
{
|
||||||
|
$this->centerResolverManager = $manager;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\MainBundle\Tests\Workflow\EventSubscriber;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Entity\User;
|
||||||
|
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||||
|
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
|
||||||
|
use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter;
|
||||||
|
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
|
||||||
|
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
|
||||||
|
use Chill\MainBundle\Workflow\EventSubscriber\EntityWorkflowGuardUnsignedTransition;
|
||||||
|
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
|
||||||
|
use Chill\PersonBundle\Entity\Person;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Prophecy\Argument;
|
||||||
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||||
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
use Symfony\Component\Workflow\DefinitionBuilder;
|
||||||
|
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
|
||||||
|
use Symfony\Component\Workflow\Registry;
|
||||||
|
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
|
||||||
|
use Symfony\Component\Workflow\Transition;
|
||||||
|
use Symfony\Component\Workflow\TransitionBlocker;
|
||||||
|
use Symfony\Component\Workflow\Workflow;
|
||||||
|
use Symfony\Component\Workflow\WorkflowInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class EntityWorkflowGuardUnsignedTransitionTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProphecyTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider guardWaitingForSignatureWithoutPermissionToApplyAllTransitionsProvider
|
||||||
|
*/
|
||||||
|
public function testGuardWaitingForSignatureWithoutPermissionToApplyAllTransitions(EntityWorkflow $entityWorkflow, string $transition, array $expectedErrors, array $expectedParameters, string $message)
|
||||||
|
{
|
||||||
|
$chillEntityRender = $this->prophesize(ChillEntityRenderManagerInterface::class);
|
||||||
|
$chillEntityRender->renderString(Argument::type('object'), Argument::type('array'))->will(fn ($args) => spl_object_hash($args[0]));
|
||||||
|
|
||||||
|
$security = $this->prophesize(Security::class);
|
||||||
|
if ([] !== $expectedErrors) {
|
||||||
|
$security->isGranted(EntityWorkflowTransitionVoter::APPLY_ALL_TRANSITIONS, $entityWorkflow)
|
||||||
|
->shouldBeCalled()
|
||||||
|
->willReturn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry = self::buildRegistry($chillEntityRender->reveal(), $security->reveal());
|
||||||
|
|
||||||
|
$workflow = $registry->get($entityWorkflow, 'dummy');
|
||||||
|
|
||||||
|
$actual = $workflow->buildTransitionBlockerList($entityWorkflow, $transition);
|
||||||
|
|
||||||
|
self::assertCount(count($expectedErrors), $actual, $message);
|
||||||
|
$blockers = iterator_to_array($actual->getIterator());
|
||||||
|
|
||||||
|
if ([] !== $expectedErrors) {
|
||||||
|
foreach ($expectedErrors as $k => $expectedError) {
|
||||||
|
self::assertcontains($expectedError, array_map(fn (TransitionBlocker $blocker) => $blocker->getCode(), $blockers));
|
||||||
|
self::assertEquals($expectedParameters[$k], $blockers[$k]->getParameters());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider guardWaitingForSignatureWithPermissionToApplyAllTransitionsProvider
|
||||||
|
*/
|
||||||
|
public function testGuardWaitingForSignatureWithPermissionToApplyAllTransitions(EntityWorkflow $entityWorkflow, string $transition, bool $expectIsGranted, string $message)
|
||||||
|
{
|
||||||
|
$chillEntityRender = $this->prophesize(ChillEntityRenderManagerInterface::class);
|
||||||
|
$chillEntityRender->renderString(Argument::type('object'), Argument::type('array'))->will(fn ($args) => spl_object_hash($args[0]));
|
||||||
|
|
||||||
|
$security = $this->prophesize(Security::class);
|
||||||
|
$isGranted = $security->isGranted(EntityWorkflowTransitionVoter::APPLY_ALL_TRANSITIONS, Argument::type(EntityWorkflow::class));
|
||||||
|
if ($expectIsGranted) {
|
||||||
|
$isGranted->shouldBeCalled();
|
||||||
|
}
|
||||||
|
$isGranted->willReturn(true);
|
||||||
|
|
||||||
|
$registry = self::buildRegistry($chillEntityRender->reveal(), $security->reveal());
|
||||||
|
|
||||||
|
$workflow = $registry->get($entityWorkflow, 'dummy');
|
||||||
|
|
||||||
|
$actual = $workflow->buildTransitionBlockerList($entityWorkflow, $transition);
|
||||||
|
|
||||||
|
self::assertCount(0, $actual, $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function guardWaitingForSignatureWithPermissionToApplyAllTransitionsProvider(): iterable
|
||||||
|
{
|
||||||
|
$registry = self::buildRegistry();
|
||||||
|
$entityWorkflow = new EntityWorkflow();
|
||||||
|
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||||
|
$dto->futureDestUsers = [$user = new User()];
|
||||||
|
$dto->futureUserSignature = $user;
|
||||||
|
|
||||||
|
$workflow = $registry->get($entityWorkflow, 'dummy');
|
||||||
|
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => new \DateTimeImmutable(), 'byUser' => new User(), 'transition' => 'to_signature']);
|
||||||
|
|
||||||
|
yield [$entityWorkflow, 'to_post-signature', true, 'A transition forward is allowed, even if a signature is pending, because the user has permission to apply all transition'];
|
||||||
|
yield [$entityWorkflow, 'to_cancel', false, 'A transition backward is allowed, even if a signature is pending'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function guardWaitingForSignatureWithoutPermissionToApplyAllTransitionsProvider(): iterable
|
||||||
|
{
|
||||||
|
$registry = self::buildRegistry();
|
||||||
|
$entityWorkflow = new EntityWorkflow();
|
||||||
|
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||||
|
$dto->futureDestUsers = [$user = new User()];
|
||||||
|
$dto->futureUserSignature = $user;
|
||||||
|
|
||||||
|
$workflow = $registry->get($entityWorkflow, 'dummy');
|
||||||
|
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => new \DateTimeImmutable(), 'byUser' => new User(), 'transition' => 'to_signature']);
|
||||||
|
|
||||||
|
yield [$entityWorkflow, 'to_post-signature', ['2eabe9e6-79c2-11ef-986c-2ba376180859'], [['signer' => spl_object_hash($user)]], 'A transition forward is blocked if a signature is pending'];
|
||||||
|
yield [$entityWorkflow, 'to_cancel', [], [], 'A transition backward is allowed, even if a signature is pending'];
|
||||||
|
|
||||||
|
$registry = self::buildRegistry();
|
||||||
|
$entityWorkflow = new EntityWorkflow();
|
||||||
|
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||||
|
$dto->futureDestUsers = [$user = new User()];
|
||||||
|
$dto->futureUserSignature = $user;
|
||||||
|
|
||||||
|
$workflow = $registry->get($entityWorkflow, 'dummy');
|
||||||
|
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => new \DateTimeImmutable(), 'byUser' => new User(), 'transition' => 'to_signature']);
|
||||||
|
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
|
||||||
|
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED);
|
||||||
|
|
||||||
|
yield [$entityWorkflow, 'to_post-signature', [], [], 'A transition forward is allowed when the signature is applyied'];
|
||||||
|
|
||||||
|
$registry = self::buildRegistry();
|
||||||
|
$entityWorkflow = new EntityWorkflow();
|
||||||
|
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||||
|
$dto->futureDestUsers = [new User()];
|
||||||
|
$dto->futurePersonSignatures = [new Person(), $p2 = new Person()];
|
||||||
|
|
||||||
|
$workflow = $registry->get($entityWorkflow, 'dummy');
|
||||||
|
$workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => new \DateTimeImmutable(), 'byUser' => new User(), 'transition' => 'to_signature']);
|
||||||
|
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
|
||||||
|
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED);
|
||||||
|
|
||||||
|
yield [$entityWorkflow, 'to_post-signature', ['2eabe9e6-79c2-11ef-986c-2ba376180859'], [['signer' => spl_object_hash($p2)]], 'A transition forward is not allowed as a signature is still pending'];
|
||||||
|
yield [$entityWorkflow, 'to_cancel', [], [], 'A transition backward is allowed, even if a signature is pending'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildRegistry(?ChillEntityRenderManagerInterface $chillEntityRender = null, ?Security $security = null): Registry
|
||||||
|
{
|
||||||
|
$builder = new DefinitionBuilder();
|
||||||
|
$builder
|
||||||
|
->setInitialPlaces('initial')
|
||||||
|
->addPlaces(['initial', 'signature', 'post-signature', 'cancel'])
|
||||||
|
->addTransition(new Transition('to_signature', 'initial', 'signature'))
|
||||||
|
->addTransition($postSignature = new Transition('to_post-signature', 'signature', 'post-signature'))
|
||||||
|
->addTransition($cancel = new Transition('to_cancel', 'signature', 'cancel'))
|
||||||
|
;
|
||||||
|
|
||||||
|
$transitionsMetadata = new \SplObjectStorage();
|
||||||
|
$transitionsMetadata->attach($postSignature, ['isForward' => true]);
|
||||||
|
$transitionsMetadata->attach($cancel, ['isForward' => false]);
|
||||||
|
|
||||||
|
$metadata = new InMemoryMetadataStore(
|
||||||
|
placesMetadata: ['signature' => ['isSignature' => ['person', 'user']]],
|
||||||
|
transitionsMetadata: $transitionsMetadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
$builder->setMetadataStore($metadata);
|
||||||
|
|
||||||
|
if (null !== $chillEntityRender) {
|
||||||
|
$eventDispatcher = new EventDispatcher();
|
||||||
|
$eventDispatcher->addSubscriber(new EntityWorkflowGuardUnsignedTransition($chillEntityRender, $security));
|
||||||
|
}
|
||||||
|
|
||||||
|
$workflow = new Workflow(
|
||||||
|
$builder->build(),
|
||||||
|
new EntityWorkflowMarkingStore(),
|
||||||
|
$eventDispatcher ?? null,
|
||||||
|
'dummy',
|
||||||
|
);
|
||||||
|
|
||||||
|
$registry = new Registry();
|
||||||
|
$registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface {
|
||||||
|
public function supports(WorkflowInterface $workflow, object $subject): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $registry;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\MainBundle\Workflow\EventSubscriber;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||||
|
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
|
||||||
|
use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter;
|
||||||
|
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
use Symfony\Component\Workflow\Event\GuardEvent;
|
||||||
|
use Symfony\Component\Workflow\TransitionBlocker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block the transition on EntityWorkflow if there is a still pending signature.
|
||||||
|
*/
|
||||||
|
final readonly class EntityWorkflowGuardUnsignedTransition implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public function __construct(private ChillEntityRenderManagerInterface $chillEntityRenderManager, private Security $security) {}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'workflow.guard' => [
|
||||||
|
['guardWaitingForSignature', 0],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function guardWaitingForSignature(GuardEvent $event): void
|
||||||
|
{
|
||||||
|
$entityWorkflow = $event->getSubject();
|
||||||
|
|
||||||
|
if (!$entityWorkflow instanceof EntityWorkflow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transitionMetadata = $event->getWorkflow()->getMetadataStore()->getTransitionMetadata($event->getTransition());
|
||||||
|
|
||||||
|
if (false === ($transitionMetadata['isForward'] ?? true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entityWorkflow->getCurrentStep()->getSignatures() as $signature) {
|
||||||
|
if (EntityWorkflowSignatureStateEnum::PENDING === $signature->getState()) {
|
||||||
|
if ($this->security->isGranted(EntityWorkflowTransitionVoter::APPLY_ALL_TRANSITIONS, $entityWorkflow)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->addTransitionBlocker(
|
||||||
|
new TransitionBlocker(
|
||||||
|
'workflow.blocked_waiting_for_pending_signer',
|
||||||
|
'2eabe9e6-79c2-11ef-986c-2ba376180859',
|
||||||
|
['signer' => $this->chillEntityRenderManager->renderString($signature->getSigner(), ['addAge' => false])]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\PersonBundle\Security\CenterResolver;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Entity\Center;
|
||||||
|
use Chill\MainBundle\Security\Resolver\CenterResolverInterface;
|
||||||
|
use Chill\MainBundle\Security\Resolver\ManagerAwareCenterResolverInterface;
|
||||||
|
use Chill\MainBundle\Security\Resolver\ManagerAwareCenterResolverTrait;
|
||||||
|
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument;
|
||||||
|
|
||||||
|
final class AccompanyingPeriodWorkEvaluationDocumentCenterResolver implements CenterResolverInterface, ManagerAwareCenterResolverInterface
|
||||||
|
{
|
||||||
|
use ManagerAwareCenterResolverTrait;
|
||||||
|
|
||||||
|
public static function getDefaultPriority(): int
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveCenter($entity, ?array $options = []): Center|array
|
||||||
|
{
|
||||||
|
/* @var $entity AccompanyingPeriodWorkEvaluationDocument */
|
||||||
|
return $this->centerResolverManager->resolveCenters($entity->getAccompanyingPeriodWorkEvaluation()
|
||||||
|
->getAccompanyingPeriodWork()->getAccompanyingPeriod(), $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports($entity, ?array $options = []): bool
|
||||||
|
{
|
||||||
|
return $entity instanceof AccompanyingPeriodWorkEvaluationDocument;
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user