Merge branch 'develop' into fix/project-creation-workflow
This commit is contained in:
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
@@ -13,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Core\Domain\Enum\ContractType;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserArchiveProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
@@ -47,7 +50,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')", processor: UserArchiveProcessor::class),
|
||||
new Get(
|
||||
uriTemplate: '/users/{id}/rbac',
|
||||
security: "is_granted('core.users.manage')",
|
||||
@@ -63,6 +66,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
],
|
||||
denormalizationContext: ['groups' => ['user:write']],
|
||||
)]
|
||||
// Archived users are hidden from the default /users collection by
|
||||
// ExcludeArchivedUserExtension; an admin can still list them with ?archived=true.
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||
#[Auditable]
|
||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
@@ -111,6 +117,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $avatarFileName = null;
|
||||
|
||||
/**
|
||||
* Soft-delete flag. Archived users are kept for referential integrity
|
||||
* (tasks, time entries, notifications…) but cannot log in and are hidden
|
||||
* from selectable user lists.
|
||||
*/
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private bool $archived = false;
|
||||
|
||||
// --- HR / absence management fields (readable only by an admin or the user themselves) ---
|
||||
|
||||
/** Whether this user is an employee subject to absence management. */
|
||||
@@ -228,6 +244,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
||||
return (string) $this->username;
|
||||
}
|
||||
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->archived;
|
||||
}
|
||||
|
||||
public function setArchived(bool $archived): static
|
||||
{
|
||||
$this->archived = $archived;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getRoles(): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\Extension;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
use function array_key_exists;
|
||||
|
||||
/**
|
||||
* Hides archived (soft-deleted) users from the `/users` collection so they are
|
||||
* no longer offered as assignees/collaborators, while existing references to
|
||||
* them (already stored on tasks, time entries…) keep resolving normally.
|
||||
*
|
||||
* An admin can opt back in to see archived users — e.g. to restore one — by
|
||||
* passing the `archived` query filter explicitly (`?archived=true`), in which
|
||||
* case the BooleanFilter declared on User handles the predicate instead.
|
||||
*/
|
||||
final readonly class ExcludeArchivedUserExtension implements QueryCollectionExtensionInterface
|
||||
{
|
||||
public function __construct(private Security $security) {}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
if (User::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let an admin explicitly query archived users via ?archived=...
|
||||
$filters = $context['filters'] ?? [];
|
||||
if (array_key_exists('archived', $filters) && $this->security->isGranted('ROLE_ADMIN')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$alias = $queryBuilder->getRootAliases()[0];
|
||||
$queryBuilder->andWhere(sprintf('%s.archived = false', $alias));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
use function assert;
|
||||
|
||||
/**
|
||||
* Soft-delete processor wired on the User `Delete` operation: instead of
|
||||
* removing the row (which would orphan every task / time entry / notification
|
||||
* referencing it and break their serialization), the user is archived. The
|
||||
* account is kept for referential integrity but can no longer log in
|
||||
* (ArchivedUserChecker) and is hidden from selectable user lists
|
||||
* (ExcludeArchivedUserExtension).
|
||||
*
|
||||
* @implements ProcessorInterface<User, null|User>
|
||||
*/
|
||||
final readonly class UserArchiveProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?User
|
||||
{
|
||||
assert($data instanceof User);
|
||||
|
||||
// Prevent an admin from archiving (locking out) their own account.
|
||||
$current = $this->security->getUser();
|
||||
if ($current instanceof User && $current->getId() === $data->getId()) {
|
||||
throw new AccessDeniedHttpException('You cannot archive your own account.');
|
||||
}
|
||||
|
||||
if ($data->isArchived()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data->setArchived(true);
|
||||
$data->setApiToken(null);
|
||||
$this->em->flush();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
use Symfony\Component\Security\Core\User\UserCheckerInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Rejects authentication for archived (soft-deleted) users, both at password
|
||||
* login and on every JWT-authenticated request, so an archived account is
|
||||
* effectively locked out while its data is preserved.
|
||||
*/
|
||||
final class ArchivedUserChecker implements UserCheckerInterface
|
||||
{
|
||||
public function checkPreAuth(UserInterface $user, ?TokenInterface $token = null): void
|
||||
{
|
||||
if ($user instanceof User && $user->isArchived()) {
|
||||
throw new CustomUserMessageAccountStatusException('This account has been archived.');
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
|
||||
}
|
||||
Reference in New Issue
Block a user