Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ce523019 | |||
| f221976573 | |||
| 133f205393 | |||
| d8d755d4c5 | |||
| 3ea1a31784 | |||
| a2dcab6ec1 |
@@ -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
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"lesstime": {
|
"lesstime": {
|
||||||
"type": "url",
|
"type": "http",
|
||||||
"url": "http://<ip-serveur>:8082/_mcp",
|
"url": "http://project.malio-dev.fr/_mcp",
|
||||||
"headers": {
|
"headers": { "Authorization": "Bearer <api-token>" }
|
||||||
"Authorization": "Bearer <api-token>"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Après modification, relancer la connexion avec `/mcp` dans Claude Code.
|
||||||
|
|
||||||
### Gestion des tokens API
|
### Gestion des tokens API
|
||||||
|
|
||||||
|
Générer / régénérer un token pour un utilisateur :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# En dev (container local)
|
||||||
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
|
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
|
## Déploiement
|
||||||
|
|
||||||
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
|
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`) |
|
| 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) |
|
| 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*
|
> 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
|
> 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
|
### 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
|
```env
|
||||||
SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
|
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
|
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).
|
(+ 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.
|
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`.
|
4. Tagger une version (`v*`) → la CI build l'image avec le DSN front baké → `deploy.sh`.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ security:
|
|||||||
pattern: ^/login_check
|
pattern: ^/login_check
|
||||||
stateless: true
|
stateless: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
|
||||||
login_throttling:
|
login_throttling:
|
||||||
max_attempts: 5
|
max_attempts: 5
|
||||||
interval: '1 minute'
|
interval: '1 minute'
|
||||||
@@ -41,6 +42,7 @@ security:
|
|||||||
pattern: ^/api
|
pattern: ^/api
|
||||||
stateless: true
|
stateless: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
|
||||||
jwt: ~
|
jwt: ~
|
||||||
logout:
|
logout:
|
||||||
path: /api/logout
|
path: /api/logout
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.43'
|
app.version: '0.4.44'
|
||||||
|
|||||||
@@ -11,15 +11,33 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-model="showArchived"
|
||||||
|
:label="$t('users.showArchived')"
|
||||||
|
:reserve-message-space="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="items"
|
:items="items"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
empty-message="Aucun utilisateur trouvé."
|
empty-message="Aucun utilisateur trouvé."
|
||||||
deletable
|
|
||||||
@row-click="openEdit"
|
@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 }">
|
<template #cell-roles="{ item }">
|
||||||
<span
|
<span
|
||||||
v-for="role in item.roles"
|
v-for="role in item.roles"
|
||||||
@@ -29,6 +47,27 @@
|
|||||||
{{ role }}
|
{{ role }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</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>
|
</DataTable>
|
||||||
|
|
||||||
<UserDrawer
|
<UserDrawer
|
||||||
@@ -36,12 +75,19 @@
|
|||||||
:item="selectedItem"
|
:item="selectedItem"
|
||||||
@saved="onSaved"
|
@saved="onSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmArchiveUserModal
|
||||||
|
v-model="archiveConfirmOpen"
|
||||||
|
:username="userToArchive?.username ?? ''"
|
||||||
|
@confirm="confirmArchive"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { useUserService } from '~/services/users'
|
import { useUserService } from '~/services/users'
|
||||||
|
import { useAuthStore } from '~/shared/stores/auth'
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
@@ -50,16 +96,27 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'roles', label: 'Rôles' },
|
{ 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 items = ref<UserData[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedItem = ref<UserData | null>(null)
|
const selectedItem = ref<UserData | null>(null)
|
||||||
|
const showArchived = ref(false)
|
||||||
|
const archiveConfirmOpen = ref(false)
|
||||||
|
const userToArchive = ref<UserData | null>(null)
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
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 {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -75,8 +132,23 @@ function openEdit(item: UserData) {
|
|||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
function openArchiveConfirm(item: UserData) {
|
||||||
await remove(id)
|
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()
|
await loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +156,10 @@ async function onSaved() {
|
|||||||
await loadItems()
|
await loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(showArchived, () => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadItems()
|
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>
|
||||||
@@ -194,8 +194,16 @@
|
|||||||
"created": "Utilisateur créé avec succès.",
|
"created": "Utilisateur créé avec succès.",
|
||||||
"updated": "Utilisateur mis à jour avec succès.",
|
"updated": "Utilisateur mis à jour avec succès.",
|
||||||
"deleted": "Utilisateur supprimé 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",
|
"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": {
|
"admin": {
|
||||||
"roles": {
|
"roles": {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type UserData = {
|
|||||||
effectivePermissions?: string[]
|
effectivePermissions?: string[]
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
apiToken?: string | null
|
apiToken?: string | null
|
||||||
|
// Soft-delete flag: an archived user keeps its data but cannot log in
|
||||||
|
archived?: boolean
|
||||||
// HR / absence management
|
// HR / absence management
|
||||||
isEmployee?: boolean
|
isEmployee?: boolean
|
||||||
hireDate?: string | null
|
hireDate?: string | null
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export function useUserService() {
|
|||||||
return extractHydraMembers(data)
|
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> {
|
async function getById(id: number): Promise<UserData> {
|
||||||
return api.get<UserData>(`/users/${id}`)
|
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> {
|
async function remove(id: number): Promise<void> {
|
||||||
await api.delete(`/users/${id}`, {}, {
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
<php>
|
<php>
|
||||||
<ini name="display_errors" value="1" />
|
<ini name="display_errors" value="1" />
|
||||||
<ini name="error_reporting" 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="APP_ENV" value="test" force="true" />
|
||||||
<server name="SHELL_VERBOSITY" value="-1" />
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
<server name="KERNEL_CLASS" value="App\Kernel" />
|
<server name="KERNEL_CLASS" value="App\Kernel" />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Core\Domain\Entity;
|
namespace App\Module\Core\Domain\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
@@ -13,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Core\Domain\Enum\ContractType;
|
use App\Module\Core\Domain\Enum\ContractType;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
|
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\Processor\UserRbacProcessor;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
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 Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||||
new Patch(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(
|
new Get(
|
||||||
uriTemplate: '/users/{id}/rbac',
|
uriTemplate: '/users/{id}/rbac',
|
||||||
security: "is_granted('core.users.manage')",
|
security: "is_granted('core.users.manage')",
|
||||||
@@ -63,6 +66,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
],
|
],
|
||||||
denormalizationContext: ['groups' => ['user:write']],
|
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]
|
#[Auditable]
|
||||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||||
#[ORM\Table(name: '`user`')]
|
#[ORM\Table(name: '`user`')]
|
||||||
@@ -111,6 +117,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
|||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
private ?string $avatarFileName = null;
|
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) ---
|
// --- HR / absence management fields (readable only by an admin or the user themselves) ---
|
||||||
|
|
||||||
/** Whether this user is an employee subject to absence management. */
|
/** Whether this user is an employee subject to absence management. */
|
||||||
@@ -228,6 +244,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
|||||||
return (string) $this->username;
|
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> */
|
/** @return list<string> */
|
||||||
public function getRoles(): array
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user