Compare commits

..

6 Commits

Author SHA1 Message Date
Matthieu 89ce523019 feat(user) : UI archivage/désarchivage des utilisateurs côté admin
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m18s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m29s
- badge « Archivé » et libellé barré dans la liste admin
- popup de confirmation avant archivage (rappelle que c'est réversible)
- bouton de restauration (PATCH archived:false) pour les archivés
- case « Afficher les utilisateurs archivés » (filtre ?archived=true)
- masque l'action d'archivage sur son propre compte (évite le 403)
- service users : getArchived/restore, toast remove -> users.archived
- i18n FR : clés archived/restored/badge/confirmation
2026-06-26 17:08:20 +02:00
matthieu f221976573 Merge branch 'develop' into fix/user-soft-delete-orphan-references
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 41s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m0s
2026-06-26 14:17:09 +00:00
Matthieu 133f205393 test(user) : couvre le soft-delete + désarchivage admin et corrige les retours de review
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m39s
- ajoute des tests fonctionnels (archive au DELETE, exclusion de la
  collection, listing/désarchivage admin, anti-auto-archivage) et un test
  unitaire du ArchivedUserChecker
- expose un filtre BooleanFilter `archived` + bypass admin dans
  ExcludeArchivedUserExtension pour lister les archivés (?archived=true)
- rend `archived` modifiable par un admin (groupe user:write + ApiProperty
  ROLE_ADMIN) → désarchivage possible via PATCH /api/users/{id}
- RestoreMissingUsersCommand : ne compte que les insertions réelles
  (ON CONFLICT DO NOTHING n'est plus comptabilisé à tort)
- relève memory_limit des tests à 512M (boot sérialiseur API Platform)
2026-06-26 16:14:11 +02:00
Matthieu d8d755d4c5 fix(user) : archivage au lieu de suppression + réparation des références orphelines
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m1s
Un user supprimé physiquement laissait des références orphelines (task.assignee,
time entries, notifications) car les FK vers "user" ont été créées NOT VALID lors
du refactor modular-monolith : elles n'ont jamais nettoyé les orphelins legacy. La
sérialisation API Platform d'une tâche embarquant un assignee inexistant levait une
EntityNotFoundException non rattrapable (HTTP 500 sur tout PATCH/GET de ces tickets).

- User::$archived (bool) + migration (soft delete)
- Delete de User -> UserArchiveProcessor : archive (archived=true, apiToken vidé)
  au lieu de supprimer, préservant l'intégrité référentielle
- ArchivedUserChecker : login bloqué pour un user archivé (firewalls login + api)
- ExcludeArchivedUserExtension : archivés exclus de GET /api/users (assignation),
  les références existantes restent sérialisées normalement
- Commande app:restore-missing-users : recrée (en archivés) les users encore
  référencés mais supprimés, restaurant l'intégrité sans perte de données.
  Idempotente, option --dry-run. À lancer une fois en prod après déploiement.
2026-06-26 15:51:27 +02:00
gitea-actions 3ea1a31784 chore: bump version to v0.4.44
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 1m42s
2026-06-25 20:53:41 +00:00
matthieu a2dcab6ec1 docs : config MCP HTTP par poste (token local, Fedora/Windows) + rotation token
Auto Tag Develop / tag (push) Successful in 8s
Le serveur MCP HTTP (token Bearer) se configure dans la config Claude Code
locale (jamais dans le .mcp.json versionné). Ajout de la méthode cross-OS
(claude mcp add), des emplacements de fichier par OS (Fedora/Linux, Windows,
macOS) et de la procédure de régénération du token (invalidé au reseed BDD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:53:31 +02:00
17 changed files with 732 additions and 20 deletions
+41 -9
View File
@@ -219,28 +219,60 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
}
```
### Configuration réseau (HTTP)
### Configuration réseau (HTTP) — par poste, hors git
Le transport HTTP nécessite un **token API** (Bearer), qui est un **secret** : il ne va **jamais**
dans le `.mcp.json` versionné (celui-ci ne contient que le serveur STDIO local, sans secret).
Chaque développeur configure le serveur HTTP dans sa **config Claude Code locale**.
**Méthode recommandée (identique sur Fedora, Windows et macOS) :**
```bash
claude mcp add --transport http --scope user lesstime \
http://project.malio-dev.fr/_mcp \
--header "Authorization: Bearer <api-token>"
```
- En prod : `http://project.malio-dev.fr/_mcp`
- En réseau local : `http://<ip-serveur>:8082/_mcp`
**Où c'est stocké** (si tu édites le fichier à la main, sous la clé `mcpServers`) :
| OS | Fichier de config Claude Code |
|----|-------------------------------|
| **Fedora / Linux** | `~/.claude.json` |
| **Windows** (collègue) | `%USERPROFILE%\.claude.json` (ex. `C:\Users\<user>\.claude.json`) |
| **macOS** | `~/.claude.json` |
```json
{
"mcpServers": {
"lesstime": {
"type": "url",
"url": "http://<ip-serveur>:8082/_mcp",
"headers": {
"Authorization": "Bearer <api-token>"
}
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": { "Authorization": "Bearer <api-token>" }
}
}
}
```
Après modification, relancer la connexion avec `/mcp` dans Claude Code.
### Gestion des tokens API
Générer / régénérer un token pour un utilisateur :
```bash
# En dev (container local)
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
# En prod (sur le serveur, dans infra/prod)
sudo docker compose exec -T -u www-data app php bin/console app:generate-api-token <username>
```
⚠️ Le token est **invalidé à chaque reset/reseed de la base**. Symptôme : `/mcp` renvoie
`HTTP 401 "Invalid API token"`. Il faut alors le **régénérer** (commande ci-dessus) puis remplacer
la valeur `Bearer ...` dans ta config locale (par poste).
## Déploiement
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
@@ -270,7 +302,7 @@ INFRA #146.
|---|---|---|
| Nature | process PHP qui tourne en continu | fichiers JS/HTML **statiques** (`nuxt generate`) |
| Quand le DSN est lu | au **runtime** | **figé au build** (baké dans le JS) |
| Où mettre le DSN | `infra/prod/.env` (runtime) | **secrets Gitea** → build-args de la CI |
| Où mettre le DSN | `.env` du serveur (`/var/www/lesstime/.env`) — runtime | **secrets Gitea** → build-args de la CI |
> Les erreurs partent **toujours vers GlitchTip**, jamais vers la CI. La CI ne sert qu'à *écrire*
> le DSN front dans le bundle au moment du build (il n'y a aucun process front en prod qui
@@ -278,7 +310,7 @@ INFRA #146.
### Variables
**Backend — fichier `infra/prod/.env` du serveur** (chargé via `env_file`) :
**Backend — fichier `.env` du serveur** (`/var/www/lesstime/.env`, chargé via `env_file` ; le repo ne fournit que le template `infra/prod/.env.example`) :
```env
SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
```
@@ -312,7 +344,7 @@ SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
1. Dans GlitchTip : créer les projets `lesstime-api` et `lesstime-front`, récupérer les 2 DSN
(+ un auth token pour les source maps).
2. Backend : ajouter `SENTRY_DSN` dans `infra/prod/.env` du serveur.
2. Backend : ajouter `SENTRY_DSN` dans le `.env` du serveur (`/var/www/lesstime/.env`).
3. Frontend : ajouter les secrets Gitea ci-dessus.
4. Tagger une version (`v*`) → la CI build l'image avec le DSN front baké → `deploy.sh`.
+2
View File
@@ -22,6 +22,7 @@ security:
pattern: ^/login_check
stateless: true
provider: app_user_provider
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
login_throttling:
max_attempts: 5
interval: '1 minute'
@@ -41,6 +42,7 @@ security:
pattern: ^/api
stateless: true
provider: app_user_provider
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
jwt: ~
logout:
path: /api/logout
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.43'
app.version: '0.4.44'
+82 -6
View File
@@ -11,15 +11,33 @@
/>
</div>
<div class="mt-4">
<MalioCheckbox
v-model="showArchived"
:label="$t('users.showArchived')"
:reserve-message-space="false"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
empty-message="Aucun utilisateur trouvé."
deletable
@row-click="openEdit"
@delete="(item) => handleDelete(item.id)"
>
<template #cell-username="{ item }">
<span :class="{ 'text-neutral-400 line-through': item.archived }">
{{ item.username }}
</span>
<span
v-if="item.archived"
class="ml-2 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700"
>
{{ $t('users.archivedBadge') }}
</span>
</template>
<template #cell-roles="{ item }">
<span
v-for="role in item.roles"
@@ -29,6 +47,27 @@
{{ role }}
</span>
</template>
<template #actions="{ item }">
<MalioButtonIcon
v-if="item.archived"
icon="mdi:restore"
:aria-label="$t('users.restore')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-primary-500"
@click.stop="handleRestore(item)"
/>
<MalioButtonIcon
v-else-if="item.id !== currentUserId"
icon="mdi:delete-outline"
:aria-label="$t('users.archive')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
@click.stop="openArchiveConfirm(item)"
/>
</template>
</DataTable>
<UserDrawer
@@ -36,12 +75,19 @@
:item="selectedItem"
@saved="onSaved"
/>
<ConfirmArchiveUserModal
v-model="archiveConfirmOpen"
:username="userToArchive?.username ?? ''"
@confirm="confirmArchive"
/>
</div>
</template>
<script setup lang="ts">
import type { UserData } from '~/services/dto/user-data'
import { useUserService } from '~/services/users'
import { useAuthStore } from '~/shared/stores/auth'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -50,16 +96,27 @@ const columns: DataTableColumn[] = [
{ key: 'roles', label: 'Rôles' },
]
const { getAll, remove } = useUserService()
const { getAll, getArchived, remove, restore } = useUserService()
const authStore = useAuthStore()
const currentUserId = computed(() => authStore.user?.id)
const items = ref<UserData[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<UserData | null>(null)
const showArchived = ref(false)
const archiveConfirmOpen = ref(false)
const userToArchive = ref<UserData | null>(null)
async function loadItems() {
isLoading.value = true
try {
items.value = await getAll()
if (showArchived.value) {
const [active, archived] = await Promise.all([getAll(), getArchived()])
items.value = [...active, ...archived]
} else {
items.value = await getAll()
}
} finally {
isLoading.value = false
}
@@ -75,8 +132,23 @@ function openEdit(item: UserData) {
drawerOpen.value = true
}
async function handleDelete(id: number) {
await remove(id)
function openArchiveConfirm(item: UserData) {
userToArchive.value = item
archiveConfirmOpen.value = true
}
async function confirmArchive() {
if (!userToArchive.value) {
return
}
await remove(userToArchive.value.id)
archiveConfirmOpen.value = false
userToArchive.value = null
await loadItems()
}
async function handleRestore(item: UserData) {
await restore(item.id)
await loadItems()
}
@@ -84,6 +156,10 @@ async function onSaved() {
await loadItems()
}
watch(showArchived, () => {
loadItems()
})
onMounted(() => {
loadItems()
})
@@ -0,0 +1,57 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('users.archiveConfirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('users.archiveConfirmMessage', { username }) }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('users.archive')"
button-class="w-auto px-4"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
username: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
+9 -1
View File
@@ -194,8 +194,16 @@
"created": "Utilisateur créé avec succès.",
"updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès.",
"archived": "Utilisateur archivé avec succès.",
"restored": "Utilisateur restauré avec succès.",
"addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur"
"editUser": "Modifier un utilisateur",
"archivedBadge": "Archivé",
"showArchived": "Afficher les utilisateurs archivés",
"archive": "Archiver",
"restore": "Restaurer",
"archiveConfirmTitle": "Archiver l'utilisateur",
"archiveConfirmMessage": "Êtes-vous sûr de vouloir archiver l'utilisateur « {username} » ? Son compte sera désactivé (il ne pourra plus se connecter), mais ses données et son historique restent conservés. Vous pourrez le restaurer plus tard."
},
"admin": {
"roles": {
+2
View File
@@ -10,6 +10,8 @@ export type UserData = {
effectivePermissions?: string[]
avatarUrl?: string | null
apiToken?: string | null
// Soft-delete flag: an archived user keeps its data but cannot log in
archived?: boolean
// HR / absence management
isEmployee?: boolean
hireDate?: string | null
+17 -2
View File
@@ -10,6 +10,13 @@ export function useUserService() {
return extractHydraMembers(data)
}
// Archived users are hidden from the default collection; an admin lists
// them explicitly via the `archived` filter (handled server-side).
async function getArchived(): Promise<UserData[]> {
const data = await api.get<HydraCollection<UserData>>('/users?archived=true')
return extractHydraMembers(data)
}
async function getById(id: number): Promise<UserData> {
return api.get<UserData>(`/users/${id}`)
}
@@ -26,11 +33,19 @@ export function useUserService() {
})
}
// Deleting a user is a soft delete server-side: the account is archived
// (kept for referential integrity) rather than removed.
async function remove(id: number): Promise<void> {
await api.delete(`/users/${id}`, {}, {
toastSuccessKey: 'users.deleted',
toastSuccessKey: 'users.archived',
})
}
return { getAll, getById, create, update, remove }
async function restore(id: number): Promise<UserData> {
return api.patch<UserData>(`/users/${id}`, { archived: false }, {
toastSuccessKey: 'users.restored',
})
}
return { getAll, getArchived, getById, create, update, remove, restore }
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds a soft-delete flag on user. Deleting a user now archives it instead of
* removing the row, preserving referential integrity (tasks, time entries,
* notifications…). Existing users are kept active (archived = false).
*/
final class Version20260626153721 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add archived flag on user (soft delete)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" ADD archived BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP archived');
}
}
+4
View File
@@ -13,6 +13,10 @@
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<!-- API Platform's serializer/metadata boot is memory-hungry on the first
call in a process (cold phpdoc + serializer metadata). 128M is too
tight for non-paginated collections such as GET /api/users. -->
<ini name="memory_limit" value="512M" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="KERNEL_CLASS" value="App\Kernel" />
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Module\Core\Domain\Entity\User;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use function sprintf;
/**
* Recreates user rows that are still referenced by other tables but no longer
* exist (legacy hard-deletes performed before the foreign keys enforced
* ON DELETE SET NULL / CASCADE). Recreated accounts are archived: their data
* (tasks, time entries, notifications…) is preserved and references become
* valid again, fixing the serialization crash (EntityNotFoundException), but
* the accounts cannot log in and are hidden from selectable user lists.
*
* Idempotent and non-destructive — nothing is deleted.
*/
#[AsCommand(
name: 'app:restore-missing-users',
description: 'Recreate (as archived) users that are still referenced but were hard-deleted, to restore referential integrity',
)]
final class RestoreMissingUsersCommand extends Command
{
public function __construct(
private readonly Connection $connection,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'List missing user ids without recreating them');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
// 1. Discover every (table, column) that references "user" via a foreign key.
$references = $this->connection->fetchAllAssociative(<<<'SQL'
SELECT t.relname AS child_table, a.attname AS child_col
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_class rt ON rt.oid = c.confrelid
JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.contype = 'f' AND rt.relname = 'user'
ORDER BY t.relname, a.attname
SQL);
// 2. Collect distinct orphan ids across all those columns.
$missingIds = [];
foreach ($references as $ref) {
$table = $ref['child_table'];
$col = $ref['child_col'];
$ids = $this->connection->fetchFirstColumn(sprintf(
'SELECT DISTINCT %1$s FROM %2$s WHERE %1$s IS NOT NULL AND %1$s NOT IN (SELECT id FROM "user")',
$this->connection->quoteIdentifier($col),
$this->connection->quoteIdentifier($table),
));
foreach ($ids as $id) {
$missingIds[(int) $id] = true;
}
}
$missingIds = array_keys($missingIds);
sort($missingIds);
$io->section(sprintf('%d foreign-key column(s) scanned', count($references)));
if ([] === $missingIds) {
$io->success('No missing users referenced. Nothing to restore.');
return Command::SUCCESS;
}
$io->writeln(sprintf('Missing user id(s): %s', implode(', ', $missingIds)));
if ($dryRun) {
$io->note('Dry run — no user recreated.');
return Command::SUCCESS;
}
// 3. Recreate each missing user as an archived placeholder, preserving its id.
$hash = $this->passwordHasher->hashPassword(new User(), bin2hex(random_bytes(16)));
$created = 0;
foreach ($missingIds as $id) {
$inserted = $this->connection->executeStatement(
<<<'SQL'
INSERT INTO "user"
(id, username, first_name, last_name, roles, password, created_at,
is_employee, work_time_ratio, annual_leave_days, reference_period_start,
initial_leave_balance, archived)
VALUES
(:id, :username, :firstName, :lastName, :roles, :password, NOW(),
false, 1.0, 25.0, '06-01', 0.0, true)
ON CONFLICT (id) DO NOTHING
SQL,
[
'id' => $id,
'username' => sprintf('deleted-user-%d', $id),
'firstName' => 'Compte',
'lastName' => sprintf('supprimé #%d', $id),
'roles' => json_encode(['ROLE_USER'], JSON_THROW_ON_ERROR),
'password' => $hash,
],
);
// ON CONFLICT may have skipped an already-present row — only count real inserts.
if ($inserted > 0) {
++$created;
$io->writeln(sprintf(' ✓ user #%d recreated (archived)', $id));
} else {
$io->writeln(sprintf(' • user #%d already present — skipped', $id));
}
}
$io->success(sprintf('%d user(s) restored as archived. References are valid again — no data lost.', $created));
return Command::SUCCESS;
}
}
+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 {}
}
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Covers the soft-delete behaviour: deleting a user archives it (the row is
* kept so referencing tasks/time entries still serialize), archived users are
* hidden from the default collection but an admin can list and restore them.
*
* @internal
*/
final class UserArchiveApiTest extends WebTestCase
{
public function testDeleteArchivesUserInsteadOfRemovingIt(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$target = $this->createUser($em, 'archive-target-'.uniqid());
$em->flush();
$targetId = $target->getId();
$this->loginAdmin($client);
$client->request('DELETE', '/api/users/'.$targetId);
self::assertResponseStatusCodeSame(204);
$em->clear();
$reloaded = $em->getRepository(User::class)->find($targetId);
self::assertInstanceOf(User::class, $reloaded, 'Row must still exist (soft delete)');
self::assertTrue($reloaded->isArchived(), 'User must be flagged archived');
self::assertNull($reloaded->getApiToken(), 'API token must be cleared on archive');
}
public function testAdminCannotArchiveOwnAccount(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAdmin($client);
$adminId = $this->userId('admin');
$client->request('DELETE', '/api/users/'.$adminId);
self::assertResponseStatusCodeSame(403);
$em->clear();
$admin = $em->getRepository(User::class)->find($adminId);
self::assertFalse($admin->isArchived(), 'Admin must not have archived itself');
}
public function testArchivedUserIsHiddenFromDefaultCollection(): void
{
$client = self::createClient();
$username = $this->createArchivedUser();
$this->loginAdmin($client);
$client->request('GET', '/api/users', server: ['HTTP_ACCEPT' => 'application/json']);
self::assertResponseIsSuccessful();
$usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username');
self::assertNotContains($username, $usernames, 'Archived user must not appear in the default list');
}
public function testAdminCanListArchivedUsersViaFilter(): void
{
$client = self::createClient();
$username = $this->createArchivedUser();
$this->loginAdmin($client);
$client->request('GET', '/api/users?archived=true', server: ['HTTP_ACCEPT' => 'application/json']);
self::assertResponseIsSuccessful();
$usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username');
self::assertContains($username, $usernames, 'Admin must be able to list archived users via ?archived=true');
}
public function testAdminCanRestoreUserViaPatch(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser($em, 'restore-target-'.uniqid());
$user->setArchived(true);
$em->flush();
$userId = $user->getId();
$em->clear();
$this->loginAdmin($client);
$client->request('PATCH', '/api/users/'.$userId, server: [
'CONTENT_TYPE' => 'application/merge-patch+json',
], content: json_encode(['archived' => false]));
self::assertResponseIsSuccessful();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($userId);
self::assertFalse($reloaded->isArchived(), 'Admin PATCH must be able to un-archive a user');
}
private function createArchivedUser(): string
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$username = 'archived-'.uniqid();
$user = $this->createUser($em, $username);
$user->setArchived(true);
$em->flush();
$em->clear();
return $username;
}
private function createUser(EntityManagerInterface $em, string $username): User
{
$user = new User();
$user->setUsername($username);
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$em->persist($user);
return $user;
}
private function loginAdmin(KernelBrowser $client): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $user);
$client->loginUser($user);
}
private function userId(string $username): int
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user);
return $user->getId();
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Infrastructure\Security\ArchivedUserChecker;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\InMemoryUser;
/**
* @internal
*/
final class ArchivedUserCheckerTest extends TestCase
{
public function testArchivedUserIsRejectedPreAuth(): void
{
$user = new User()->setArchived(true);
$this->expectException(CustomUserMessageAccountStatusException::class);
new ArchivedUserChecker()->checkPreAuth($user);
}
public function testActiveUserPassesPreAuth(): void
{
$user = new User()->setArchived(false);
new ArchivedUserChecker()->checkPreAuth($user);
$this->addToAssertionCount(1);
}
public function testNonAppUserIsIgnored(): void
{
// A user that is not our entity must not be rejected by this checker.
new ArchivedUserChecker()->checkPreAuth(new InMemoryUser('someone', null));
$this->addToAssertionCount(1);
}
}