Merge branch 'develop' into fix/project-creation-workflow
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m28s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m40s

This commit is contained in:
2026-06-26 14:50:08 +00:00
60 changed files with 3552 additions and 767 deletions
+29 -1
View File
@@ -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 {}
}