Compare commits

..

7 Commits

Author SHA1 Message Date
edbe54cb8a fix(admin) : recherche insensible a la casse + label permissions dans filtres
SearchFilter partial etait case-sensitive en PostgreSQL : taper "ad" ne
trouvait pas "Administrateur". Passage en strategy `ipartial` (ILIKE) sur
tous les champs texte filtrables :
- Role.label / Role.code
- Site.name / Site.city / Site.postalCode
- User.username

Les filtres exacts sur relations (rbacRoles.code, sites.name,
permissions.code) restent en `exact` — alimentes par des <select> donc
casse maitrisee.

Test de non-regression ajoute : chercher "ad" (minuscule) trouve bien
"Administrateur" (A majuscule) sur /api/roles?label=ad.

UI select permissions du filtre /admin/roles : affiche `perm.label`
("Gerer les roles et permissions") au lieu de `perm.code`
("core.roles.manage"). Value reste sur `code` pour le backend. Tri
par label pour coherence alphabetique avec l'affichage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:28:01 +02:00
1550f46b23 Merge remote-tracking branch 'origin/feat/module-site-backend' into feat/admin-tables-filter-pagination
# Conflicts:
#	frontend/modules/sites/pages/admin/sites.vue
2026-04-20 17:04:04 +02:00
cb6d2d72ec feat(admin) : filtres + pagination serveur sur /admin/users/sites/roles
Ajoute le filtrage par colonne et la pagination negociee via query params
sur les 3 DataTables admin existantes. Tout est cote serveur (API Platform
SearchFilter + BooleanFilter) pour scaler naturellement.

Backend :
- api_platform.yaml : scan du mapping Sites + pagination_client_items_per_page
  (avec borne max 100 pour proteger contre les payloads exagerement grands).
- User : SearchFilter username (partial), rbacRoles.code (exact),
  sites.name (exact) + BooleanFilter isAdmin.
- Site : SearchFilter name/city/postalCode (partial).
- Role : SearchFilter label/code (partial), permissions.code (exact).
  (BooleanFilter isSystem deja present.)

Frontend :
- Composable useDataTableServerState (shared) : singleton de page/perPage/
  filters avec debounce 300ms sur les filters, fetch immediat sur page/
  perPage, reset page=1 au changement filter, token anti-race-condition.
- Pages admin : chaque filtre dans un slot #header-{key} (input text avec
  debounce, select mono-selection pour les relations). Font-size 20px sur
  les inputs de filtre.
- /admin/users : colonne Sites + filtre Sites conditionnes par
  useModules().isModuleActive('sites') — preserve l'invariant "module
  desactivable sans casse".

Tests : 215/215 PHPUnit (14 nouveaux filtres/pagination) + 48/48 Vitest
(8 nouveaux useDataTableServerState).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:00:34 +02:00
Matthieu
a15fc83222 fix(sites-front) : refresh state apres switch/delete/401 + redirect logout
- logout.vue : navigateTo('/login') dans le finally, garanti meme si
  auth.logout() rejette.
- auth.ts : systeme de callbacks onAuthSessionCleared appeles par
  clearSession() (intercepteur 401 de useApi). Les composables modules
  s'abonnent pour reset leur state sans que Shared n'importe depuis
  modules/ (Option C validee par CLAUDE.md, module -> shared autorise).
- useCurrentSite.ts : enregistre un reset callback + apres un switch
  reussi, rafraichit useSidebar().loadSidebar() + refreshNuxtData()
  (sinon donnees de page obsoletes cote ancien site sous toast success).
- SiteSelector.vue : le court-circuit "tile deja active" est retire
  pour permettre un PATCH de resync quand un autre onglet a bascule le
  site entre temps. TODO cross-tab : ecouter un storage event dedie.
- sites.vue admin : auth.refreshUser() apres delete pour refleter le
  ON DELETE SET NULL cote user.current_site_id.
- Specs vitest : stub useSidebar/refreshNuxtData, test "tile active"
  retourne sur le nouveau contrat PATCH-toujours.
2026-04-20 16:47:57 +02:00
Matthieu
caae752130 fix(sites) : processors transactionnels + garde sites.manage + anti-TOCTOU
- UserRbacProcessor : persist + ensureCurrentSiteConsistency wrappes dans
  wrapInTransaction (plus de double flush non atomique qui pouvait laisser
  currentSite orphelin sur un crash entre les deux flush).
- UserRbacProcessor : detecte la mutation de `sites` via
  PersistentCollection::isDirty() et verifie is_granted('sites.manage')
  avant de deleguer (empeche core.users.manage de contourner sites.manage).
- UserRbacProcessor : skip ensureCurrentSiteConsistency si ni sites ni
  currentSite n'ont ete modifies (plus de bascule silencieuse de site sur
  un simple toggle isAdmin apres suppression de site).
- CurrentSiteProcessor : refresh($user) avant hasSite() pour fermer la
  fenetre TOCTOU entre /rbac revoke et /me/current-site. Catch
  OptimisticLockException pour etre pret a un futur @ORM\Version.
- SiteAwareInjectionProcessor : valide un site explicite contre
  $user->getSites() (bypass via sites.bypass_scope) — bloque le cross-site
  write quand l'entite expose `site` en ecriture.
2026-04-20 16:47:28 +02:00
Matthieu
8bedab407d feat(sites) : scope /api/sites et /api/users aux sites autorises du caller
- SiteCollectionScopedExtension filtre /api/sites aux sites du user
  (name/adresse/CP/ville plus lisibles par un delegataire sites.view qui
  n'appartient pas a ces sites). Bypass via sites.bypass_scope.
- UserSiteScopedExtension filtre /api/users aux users partageant au moins
  un site avec le caller. Empeche un delegataire de core.users.view
  d'enumerer l'organigramme complet + les sites de tous les tenants.
- Helper createUserWithPermission rattache le user jetable a tous les
  sites fixtures, sinon le scoping le rend aveugle aux cibles.
- test_target de UserRbacApiTest attache de meme aux sites pour rester
  visible depuis un caller non-admin.
- testUserCannotSwitchToUnauthorizedSite : 403 -> 400 (anti-enumeration).
2026-04-20 16:46:57 +02:00
Matthieu
fd5d3fe36f refactor(sites) : decouple module Sites via SiteInterface + leaks groupes user:list
- Introduit Shared/Domain/Contract/SiteInterface que Site implemente
- SiteAwareInterface + User.php typent contre SiteInterface (plus d'import
  direct Core -> Sites, respect regle CLAUDE.md 138)
- Exception SiteNotAuthorizedException deplacee dans Shared/, alias
  retrocompat dans le module
- Retire `sites` et `currentSite` des groupes `user:list` et `user:rbac:write`
  (info leak via /api/users, escalade core.users.manage -> sites.manage)
- User::$sites et User::$currentSite en fetch LAZY (N+1 sur /api/users paginee)
2026-04-20 16:46:27 +02:00
31 changed files with 1508 additions and 170 deletions

View File

@@ -1,14 +1,15 @@
api_platform:
title: Coltura API
version: 1.0.0
# Scan du module Core pour decouvrir les classes ApiResource et ApiFilter.
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules.
# Sans ces paths, le compile pass d'API Platform ne declare pas les
# services de filtres annotes (les filtres etaient silencieusement
# ignores sur Permission — cf. ticket #344).
# Scan des modules pour decouvrir les classes ApiResource et ApiFilter.
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource
# dans d'autres modules. Sans ces paths, le compile pass d'API Platform
# ne declare pas les services de filtres annotes (les filtres etaient
# silencieusement ignores sur Permission — cf. ticket #344).
mapping:
paths:
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
- '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
@@ -18,3 +19,10 @@ api_platform:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
# Active la negociation client de la pagination via ?itemsPerPage=X
# (necessaire pour le dropdown perPage des DataTable admin). Borne
# haute a 100 pour eviter qu'un client abuse en demandant 10000
# items d'un coup — les UIs admin n'ont jamais besoin de plus de 50
# en pratique.
pagination_client_items_per_page: true
pagination_maximum_items_per_page: 100

View File

@@ -14,16 +14,68 @@
/>
</div>
<!-- Table des roles -->
<!-- Table des roles avec filtres + pagination -->
<MalioDataTable
v-model:page="page"
v-model:per-page="perPage"
class="mt-6"
:columns="columns"
:items="roleItems"
:total-items="roles.length"
:total-items="totalItems"
:row-clickable="canManage"
:empty-message="t('admin.roles.noRoles')"
@row-click="onRowClick"
>
<template #header-label>
<input
v-model="filters.label"
type="text"
:placeholder="t('admin.roles.table.label')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #header-code>
<input
v-model="filters.code"
type="text"
:placeholder="t('admin.roles.table.code')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #header-permissions>
<select
v-model="filters['permissions.code']"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
<option value="">
{{ t('admin.roles.table.permissions') }}
</option>
<option
v-for="perm in allPermissions"
:key="perm.id"
:value="perm.code"
>
{{ perm.label }}
</option>
</select>
</template>
<template #header-system>
<select
v-model="filters.isSystem"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
<option value="">
{{ t('admin.roles.table.system') }}
</option>
<option value="true">
{{ t('common.yes') }}
</option>
<option value="false">
{{ t('common.no') }}
</option>
</select>
</template>
<template #cell-code="{ item }">
<span class="font-mono text-xs">{{ item.code }}</span>
</template>
@@ -59,7 +111,7 @@
</template>
<script setup lang="ts">
import type { Role } from '~/shared/types/rbac'
import type { Permission, Role } from '~/shared/types/rbac'
const { t } = useI18n()
const api = useApi()
@@ -68,8 +120,42 @@ const canManage = computed(() => can('core.roles.manage'))
useHead({ title: t('admin.roles.title') })
const roles = ref<Role[]>([])
const loading = ref(false)
// Etat DataTable centralise : pagination serveur + filtres debounces.
// `isSystem` est une string ('true'/'false'/'') plutot qu'un bool : les
// <select> HTML travaillent en string et API Platform BooleanFilter
// accepte les strings 'true'/'false' telles quelles.
const {
items,
totalItems,
page,
perPage,
filters,
reload,
} = useDataTableServerState<Role>('/roles', {
label: '',
code: '',
isSystem: '',
'permissions.code': '',
})
// Chargement one-shot des permissions pour alimenter le select filter.
// Independant du composable de table : cette liste ne bouge pas pendant
// la session admin.
const allPermissions = ref<Permission[]>([])
async function loadPermissions(): Promise<void> {
const data = await api.get<{ member: Permission[] }>(
'/permissions',
{ itemsPerPage: 999, orphan: false },
{ toast: false },
)
// Tri par label pour coherence avec l'affichage du <option> : l'user
// lit le label (ex: "Gerer les roles et permissions"), donc l'ordre
// alphabetique doit etre base sur ce qu'il voit, pas sur le code.
allPermissions.value = (data.member ?? []).sort(
(a, b) => a.label.localeCompare(b.label),
)
}
const columns = [
{ key: 'label', label: t('admin.roles.table.label') },
@@ -80,45 +166,31 @@ const columns = [
// Transformer les roles en items compatibles MalioDataTable
const roleItems = computed(() =>
roles.value.map(role => ({
items.value.map(role => ({
id: role.id,
label: role.label,
code: role.code,
permissions: role.permissions.length,
isSystem: role.isSystem,
system: '', // colonne geree par le slot
}))
})),
)
function getRoleById(id: number): Role | undefined {
return roles.value.find(r => r.id === id)
return items.value.find(r => r.id === id)
}
function onRowClick(item: Record<string, unknown>) {
const role = getRoleById(item.id as number)
if (role) openEditDrawer(role)
}
const drawerOpen = ref(false)
const selectedRole = ref<Role | null>(null)
const deleteModalOpen = ref(false)
const roleToDelete = ref<Role | null>(null)
const deleting = ref(false)
// Charger la liste des roles
async function loadRoles() {
loading.value = true
try {
const data = await api.get<{ member: Role[] }>(
'/roles',
{},
{ toast: false },
)
roles.value = data.member
} finally {
loading.value = false
}
}
function openCreateDrawer() {
selectedRole.value = null
drawerOpen.value = true
@@ -145,17 +217,18 @@ async function handleDelete() {
deleteModalOpen.value = false
roleToDelete.value = null
drawerOpen.value = false
await loadRoles()
reload()
} finally {
deleting.value = false
}
}
function onRoleSaved() {
loadRoles()
reload()
}
onMounted(() => {
loadRoles()
loadPermissions()
reload()
})
</script>

View File

@@ -7,16 +7,77 @@
</h1>
</div>
<!-- Table des utilisateurs -->
<!-- Table des utilisateurs avec filtres + pagination -->
<MalioDataTable
v-model:page="page"
v-model:per-page="perPage"
class="mt-6"
:columns="columns"
:items="userItems"
:total-items="users.length"
:total-items="totalItems"
:row-clickable="canManage"
:empty-message="t('admin.users.noUsers')"
@row-click="onRowClick"
>
<template #header-username>
<input
v-model="filters.username"
type="text"
:placeholder="t('admin.users.table.username')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #header-admin>
<select
v-model="filters.isAdmin"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
<option value="">
{{ t('admin.users.table.admin') }}
</option>
<option value="true">
{{ t('common.yes') }}
</option>
<option value="false">
{{ t('common.no') }}
</option>
</select>
</template>
<template #header-roles>
<select
v-model="filters['rbacRoles.code']"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
<option value="">
{{ t('admin.users.table.roles') }}
</option>
<option
v-for="role in allRoles"
:key="role.id"
:value="role.code"
>
{{ role.label }}
</option>
</select>
</template>
<template v-if="sitesModuleActive" #header-sites>
<select
v-model="filters['sites.name']"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
<option value="">
{{ t('admin.users.table.sites') }}
</option>
<option
v-for="site in allSites"
:key="site.id"
:value="site.name"
>
{{ site.name }}
</option>
</select>
</template>
<template #cell-admin="{ item }">
<span
v-if="item.admin"
@@ -37,30 +98,77 @@
</template>
<script setup lang="ts">
import type { UserListItem } from '~/shared/types/rbac'
import type { Role, UserListItem } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
const { isModuleActive } = useModules()
useHead({ title: t('admin.users.title') })
const canManage = computed(() => can('core.users.manage'))
// Conditionne la colonne Sites + le filtre Sites : si le module Sites
// est desactive, inutile de charger /api/sites ni d'afficher ces elements.
// L'invariant "module inactif = app fonctionnelle" est preserve.
const sitesModuleActive = computed(() => isModuleActive('sites'))
const users = ref<UserListItem[]>([])
// Etat DataTable centralise. On declare le filtre sites.name meme si le
// module Sites est inactif : le composable omet les filtres a valeur
// vide donc ca ne produit aucun impact cote API, et ca evite de casser
// la forme du state si le module est reactive sans reloader la page.
const {
items,
totalItems,
page,
perPage,
filters,
reload,
} = useDataTableServerState<UserListItem>('/users', {
username: '',
isAdmin: '',
'rbacRoles.code': '',
'sites.name': '',
})
const allRoles = ref<Role[]>([])
const allSites = ref<Site[]>([])
const sitesById = ref(new Map<number, Site>())
const loading = ref(false)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
const columns = [
{ key: 'username', label: t('admin.users.table.username') },
{ key: 'admin', label: t('admin.users.table.admin') },
{ key: 'roles', label: t('admin.users.table.roles') },
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
{ key: 'sites', label: t('admin.users.table.sites') },
]
async function loadFilterOptions(): Promise<void> {
const rolesPromise = api.get<{ member: Role[] }>(
'/roles',
{ itemsPerPage: 999 },
{ toast: false },
)
// /api/sites est protege par `sites.view`. On skip si module off pour
// eviter un 403 inutile dans la console devtools — la UI ne consomme
// pas le resultat dans ce cas.
const sitesPromise = sitesModuleActive.value
? api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false })
: Promise.resolve({ member: [] as Site[] })
const [rolesData, sitesData] = await Promise.all([rolesPromise, sitesPromise])
allRoles.value = rolesData.member ?? []
allSites.value = sitesData.member ?? []
sitesById.value = new Map(allSites.value.map(s => [s.id, s]))
}
// Colonnes dynamiques : on omet la colonne Sites si le module est off.
const columns = computed(() => {
const base = [
{ key: 'username', label: t('admin.users.table.username') },
{ key: 'admin', label: t('admin.users.table.admin') },
{ key: 'roles', label: t('admin.users.table.roles') },
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
]
if (sitesModuleActive.value) {
base.push({ key: 'sites', label: t('admin.users.table.sites') })
}
return base
})
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
function iriToId(iri: string): number {
@@ -68,7 +176,7 @@ function iriToId(iri: string): number {
}
const userItems = computed(() =>
users.value.map(user => ({
items.value.map(user => ({
id: user.id,
username: user.username,
admin: user.isAdmin,
@@ -76,7 +184,7 @@ const userItems = computed(() =>
directPermissions: user.directPermissions.length,
// Affichage : liste des noms de sites separes par virgule. Les IRIs
// du payload /api/users (groupe user:list) sont resolues via la Map
// construite en parallele depuis /api/sites.
// construite par loadFilterOptions. Vide si module Sites off.
sites: (user.sites ?? [])
.map(iri => sitesById.value.get(iriToId(iri))?.name)
.filter((name): name is string => Boolean(name))
@@ -84,24 +192,11 @@ const userItems = computed(() =>
})),
)
async function loadUsers() {
loading.value = true
try {
// Chargement parallele : les sites alimentent la Map de resolution
// IRI→name pour la colonne "Sites" de la table.
const [usersData, sitesData] = await Promise.all([
api.get<{ member: UserListItem[] }>('/users', {}, { toast: false }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
])
users.value = usersData.member
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
} finally {
loading.value = false
}
}
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
function getUserById(id: number): UserListItem | undefined {
return users.value.find(u => u.id === id)
return items.value.find(u => u.id === id)
}
function openDrawer(user: UserListItem) {
@@ -115,10 +210,11 @@ function onRowClick(item: Record<string, unknown>) {
}
function onUserSaved() {
loadUsers()
reload()
}
onMounted(() => {
loadUsers()
loadFilterOptions()
reload()
})
</script>

View File

@@ -20,10 +20,12 @@ onMounted(async () => {
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// l'ancien. Les trois fonctions reset sont synchrones et ne
// peuvent pas throw (juste des assignations reactives).
// navigateTo est dans le finally pour garantir la redirection
// meme si auth.logout() lance une exception (ex: reseau coupé).
resetSidebar()
resetModules()
resetCurrentSite()
await navigateTo('/login')
}
await navigateTo('/login')
})
</script>

View File

@@ -72,8 +72,14 @@ async function onChange(site: { id: string; name: string; color: string }): Prom
return
}
// Ignore les clics sur le site deja actif (pas de PATCH superflu).
if (currentSite.value && currentSite.value.id === target.id) return
// TODO(cross-tab) : si l'utilisateur a change de site dans un autre
// onglet, currentSite.value ici peut etre obsolete (state singleton
// non synchronise entre onglets). La garde ci-dessous est donc
// intentionnellement supprimee pour garantir qu'un clic sur le tile
// "actif selon cet onglet" envoie quand meme le PATCH et re-synchronise
// l'etat. Amelioration future : ecouter l'evenement `storage` sur la
// cle `coltura:site-switch` pour mettre a jour les onglets inactifs
// sans clic via auth.fetchUser() / auth.refreshUser().
try {
await switchSite(target)

View File

@@ -29,6 +29,10 @@ vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('watchEffect', watchEffect)
vi.stubGlobal('computed', computed)
vi.stubGlobal('ref', ref)
// useSidebar et refreshNuxtData sont consommes par useCurrentSite apres
// un switch reussi — stubs minimaux pour eviter ReferenceError au mount.
vi.stubGlobal('useSidebar', () => ({ loadSidebar: vi.fn() }))
vi.stubGlobal('refreshNuxtData', vi.fn())
// Stub de MalioSiteSelector : on se contente de tracker les props recues
// et de re-emettre `change` quand on le simule via `trigger`. Evite de
@@ -144,13 +148,22 @@ describe('SiteSelector', () => {
)
})
it('clic sur le tile deja actif ne declenche aucun PATCH', async () => {
it('clic sur le tile deja actif declenche un PATCH (resync cross-tab)', async () => {
// Le court-circuit "si deja actif, ne rien faire" a ete supprime
// pour couvrir le cas ou un autre onglet a modifie le site courant
// cote serveur : un clic sur la tile localement "active" (etat
// potentiellement stale) force une resync via PATCH. Le prix est un
// PATCH superflu quand l'etat local est effectivement a jour.
const wrapper = mountSelector()
await wrapper.find('[data-testid="tile-1"]').trigger('click')
await flushPromises()
expect(mockPatch).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith(
'/me/current-site',
{ site: '/api/sites/1' },
expect.anything(),
)
})
it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => {

View File

@@ -24,6 +24,14 @@ vi.stubGlobal('useAuthStore', () => ({
vi.stubGlobal('useI18n', () => ({
t: (key: string) => key,
}))
// useSidebar est consomme par useCurrentSite pour rafraichir la sidebar
// apres un switch reussi. Stub minimal retournant un loadSidebar no-op.
vi.stubGlobal('useSidebar', () => ({
loadSidebar: vi.fn(),
}))
// refreshNuxtData est appele apres un switch pour invalider les donnees
// de page precedemment fetchees. Stub no-op pour les tests unitaires.
vi.stubGlobal('refreshNuxtData', vi.fn())
const SITE_A: Site = {
id: 1,

View File

@@ -23,11 +23,21 @@
*/
import { ref } from 'vue'
import type { Site } from '~/shared/types/sites'
import { onAuthSessionCleared } from '~/shared/stores/auth'
const currentSite = ref<Site | null>(null)
const availableSites = ref<Site[]>([])
const switching = ref(false)
// Enregistrement unique au niveau module (singleton) : quand clearSession()
// est appelee par l'intercepteur 401 de useApi, le state local est purgé
// de la meme facon qu'au logout explicite (logout.vue).
onAuthSessionCleared(() => {
currentSite.value = null
availableSites.value = []
switching.value = false
})
export function useCurrentSite() {
// Resolution au setup : les 3 services doivent etre invoques dans un
// contexte composant. Leur capture ici permet a switchSite() de
@@ -35,6 +45,7 @@ export function useCurrentSite() {
const auth = useAuthStore()
const api = useApi()
const { t } = useI18n()
const { loadSidebar } = useSidebar()
/**
* Synchronise le state singleton depuis le store auth. A appeler au
@@ -75,6 +86,21 @@ export function useCurrentSite() {
// N'est appele qu'apres un succes HTTP donc pas de rollback a
// prevoir sur cette ligne.
auth.setCurrentSite(site)
// Apres un switch reussi : recharger la sidebar (les filtres de
// modules peuvent dependre du site courant via SiteScopedQueryExtension)
// et invalider toutes les donnees de page pour eviter que l'utilisateur
// voie les donnees de l'ancien site sous un toast "Site change".
try {
await loadSidebar()
} catch {
// No-op : la sidebar non rafraichie n'est pas bloquante.
}
try {
await refreshNuxtData()
} catch {
// No-op : certaines pages n'ont pas de useAsyncData a invalider.
}
} catch (error) {
currentSite.value = previousLocal
throw error

View File

@@ -14,16 +14,43 @@
/>
</div>
<!-- Table des sites -->
<!-- Table des sites avec filtres + pagination -->
<MalioDataTable
v-model:page="page"
v-model:per-page="perPage"
class="mt-6"
:columns="columns"
:items="siteItems"
:total-items="sites.length"
:total-items="totalItems"
:row-clickable="canManage"
:empty-message="t('admin.sites.noSites')"
@row-click="onRowClick"
>
<template #header-name>
<input
v-model="filters.name"
type="text"
:placeholder="t('admin.sites.table.name')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #header-city>
<input
v-model="filters.city"
type="text"
:placeholder="t('admin.sites.table.city')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #header-postalCode>
<input
v-model="filters.postalCode"
type="text"
:placeholder="t('admin.sites.table.postalCode')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #cell-color="{ item }">
<span class="inline-flex items-center gap-2">
<span
@@ -63,13 +90,26 @@ import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
const auth = useAuthStore()
const { can } = usePermissions()
const canManage = computed(() => can('sites.manage'))
useHead({ title: t('admin.sites.title') })
const sites = ref<Site[]>([])
const loading = ref(false)
// Etat DataTable centralise : pagination serveur + filtres debounces.
// Les filtres name/city/postalCode sont des partiels SearchFilter cote API.
const {
items,
totalItems,
page,
perPage,
filters,
reload,
} = useDataTableServerState<Site>('/sites', {
name: '',
city: '',
postalCode: '',
})
const columns = [
{ key: 'name', label: t('admin.sites.table.name') },
@@ -83,7 +123,7 @@ const columns = [
// `fullAddress` provient du getter computed cote backend (Site::getFullAddress)
// au format multi-lignes — on l'aplatit en virgules pour l'affichage table.
const siteItems = computed(() =>
sites.value.map(site => ({
items.value.map(site => ({
id: site.id,
name: site.name,
city: site.city,
@@ -94,7 +134,7 @@ const siteItems = computed(() =>
)
function getSiteById(id: number): Site | undefined {
return sites.value.find(s => s.id === id)
return items.value.find(s => s.id === id)
}
function onRowClick(item: Record<string, unknown>) {
@@ -108,20 +148,6 @@ const deleteModalOpen = ref(false)
const siteToDelete = ref<Site | null>(null)
const deleting = ref(false)
async function loadSites() {
loading.value = true
try {
const data = await api.get<{ member: Site[] }>(
'/sites',
{ itemsPerPage: 999 },
{ toast: false },
)
sites.value = data.member
} finally {
loading.value = false
}
}
function openCreateDrawer() {
selectedSite.value = null
drawerOpen.value = true
@@ -148,17 +174,22 @@ async function handleDelete() {
deleteModalOpen.value = false
siteToDelete.value = null
drawerOpen.value = false
await loadSites()
reload()
// Rafraichit auth.user apres suppression d'un site : le backend
// applique ON DELETE SET NULL sur user.current_site_id, donc
// auth.user.currentSite peut etre devenu null sans que le front
// le sache. refreshUser() resynchronise depuis GET /api/me.
await auth.refreshUser()
} finally {
deleting.value = false
}
}
function onSiteSaved() {
loadSites()
reload()
}
onMounted(() => {
loadSites()
reload()
})
</script>

View File

@@ -0,0 +1,203 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { nextTick } from 'vue'
import { useDataTableServerState } from '../useDataTableServerState'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
function ldResponse<T>(member: T[], totalItems?: number): { member: T[], totalItems: number } {
return { member, totalItems: totalItems ?? member.length }
}
describe('useDataTableServerState', () => {
beforeEach(() => {
mockApiGet.mockReset()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('fetch initial au premier reload() avec page=1 et perPage par defaut', async () => {
mockApiGet.mockResolvedValueOnce(ldResponse([{ id: 1 }, { id: 2 }], 42))
const { items, totalItems, reload } = useDataTableServerState('/sites', { name: '' })
reload()
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenCalledWith(
'/sites',
{ page: 1, itemsPerPage: 10 },
{ toast: false },
)
expect(items.value).toHaveLength(2)
expect(totalItems.value).toBe(42)
})
it('omet les filtres a valeur vide dans les query params', async () => {
mockApiGet.mockResolvedValueOnce(ldResponse([]))
const { reload } = useDataTableServerState('/users', {
username: '',
isAdmin: null,
})
reload()
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenCalledWith(
'/users',
{ page: 1, itemsPerPage: 10 },
{ toast: false },
)
})
it('inclut les filtres renseignes dans les query params', async () => {
// mockResolvedValue (sans Once) : chaque fetch retourne une
// reponse valide, y compris ceux declenches par le debounce des
// mutations de filters qui precedent reload().
mockApiGet.mockResolvedValue(ldResponse([]))
const { filters, reload } = useDataTableServerState('/users', {
username: '',
isAdmin: null,
})
filters.value.username = 'alice'
filters.value.isAdmin = true
reload()
await vi.runAllTimersAsync()
// Le reload() ecrase les scheduleReload en cours (clearTimeout),
// donc on verifie juste que la derniere requete emise porte bien
// les filtres + les parametres de pagination.
expect(mockApiGet).toHaveBeenLastCalledWith(
'/users',
{ page: 1, itemsPerPage: 10, username: 'alice', isAdmin: true },
{ toast: false },
)
})
it('change page declenche un fetch immediat (pas de debounce)', async () => {
mockApiGet.mockResolvedValue(ldResponse([]))
const { page, reload } = useDataTableServerState('/sites', {})
reload()
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenCalledTimes(1)
page.value = 3
await nextTick()
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenCalledTimes(2)
expect(mockApiGet).toHaveBeenLastCalledWith(
'/sites',
{ page: 3, itemsPerPage: 10 },
{ toast: false },
)
})
it('change filter debounce 300ms avant fetch', async () => {
mockApiGet.mockResolvedValue(ldResponse([]))
const { filters, reload } = useDataTableServerState('/sites', { name: '' })
reload()
await vi.runAllTimersAsync()
mockApiGet.mockClear()
filters.value.name = 'a'
await nextTick()
// Pas encore de requete : debounce en cours.
expect(mockApiGet).not.toHaveBeenCalled()
filters.value.name = 'al'
await nextTick()
filters.value.name = 'ali'
await nextTick()
// Avance le timer de 200ms : toujours pas fetch.
vi.advanceTimersByTime(200)
expect(mockApiGet).not.toHaveBeenCalled()
// Avance encore 100ms : debounce expire, fetch lance.
vi.advanceTimersByTime(100)
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenCalledTimes(1)
expect(mockApiGet).toHaveBeenCalledWith(
'/sites',
{ page: 1, itemsPerPage: 10, name: 'ali' },
{ toast: false },
)
})
it('changer un filtre reset page a 1', async () => {
mockApiGet.mockResolvedValue(ldResponse([]))
const { page, filters, reload } = useDataTableServerState('/sites', { name: '' })
reload()
await vi.runAllTimersAsync()
page.value = 5
await vi.runAllTimersAsync()
mockApiGet.mockClear()
filters.value.name = 'x'
await nextTick()
await vi.runAllTimersAsync()
// Page doit etre revenue a 1 avant le fetch.
expect(page.value).toBe(1)
expect(mockApiGet).toHaveBeenLastCalledWith(
'/sites',
expect.objectContaining({ page: 1, name: 'x' }),
{ toast: false },
)
})
it('change perPage declenche un fetch immediat', async () => {
mockApiGet.mockResolvedValue(ldResponse([]))
const { perPage, reload } = useDataTableServerState('/sites', {})
reload()
await vi.runAllTimersAsync()
mockApiGet.mockClear()
perPage.value = 25
await nextTick()
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenLastCalledWith(
'/sites',
{ page: 1, itemsPerPage: 25 },
{ toast: false },
)
})
it('race condition : seule la derniere reponse gagne', async () => {
// Scenario : user tape tres vite, 2 requetes partent, la premiere
// (plus ancienne) arrive apres la seconde. Le composable doit
// ignorer la premiere.
let resolveFirst!: (value: unknown) => void
let resolveSecond!: (value: unknown) => void
mockApiGet
.mockImplementationOnce(() => new Promise((r) => { resolveFirst = r }))
.mockImplementationOnce(() => new Promise((r) => { resolveSecond = r }))
const { items, reload } = useDataTableServerState<{ id: number }>('/sites', {})
reload() // requete #1
reload() // requete #2 (annule #1 du point de vue du token)
// Resout la seconde d'abord avec id=2
resolveSecond(ldResponse([{ id: 2 }]))
await vi.runAllTimersAsync()
expect(items.value).toEqual([{ id: 2 }])
// Resout la premiere apres avec id=1 : DOIT etre ignore.
resolveFirst(ldResponse([{ id: 1 }]))
await vi.runAllTimersAsync()
expect(items.value).toEqual([{ id: 2 }])
})
})

View File

@@ -0,0 +1,149 @@
import { ref, watch } from 'vue'
/**
* Composable generique pour les DataTables admin avec pagination, perPage
* et filtres cote serveur (API Platform + Hydra).
*
* Usage type dans une page admin :
*
* ```ts
* const { items, totalItems, page, perPage, filters, loading, reload } =
* useDataTableServerState<Site>('/sites', {
* name: '',
* city: '',
* postalCode: '',
* })
* ```
*
* Le composable :
* - traque `page`, `perPage`, et un objet `filters` reactif.
* - re-fetch automatiquement a chaque changement (debounce 300ms sur
* `filters` pour eviter un spam lors de la frappe clavier).
* - re-fetch immediat (pas de debounce) quand `page` ou `perPage` change
* — ces changements sont deja des clics user discrets.
* - reinitialise `page` a 1 des qu'un filtre bouge (coherence UX : un
* filtre ajuste ne doit pas laisser l'user sur "page 5 de 2 pages").
* - expose `loading` pour afficher un feedback pendant la requete.
* - expose `reload()` pour forcer un fetch (ex: apres une mutation
* POST/PATCH/DELETE).
*
* Type parameter T = la forme d'un item renvoye par l'API (le member[]
* du payload Hydra est type T[]).
*/
export function useDataTableServerState<T = Record<string, unknown>>(
endpoint: string,
initialFilters: Record<string, string | boolean | null> = {},
options: { debounceMs?: number, initialPerPage?: number } = {},
) {
const api = useApi()
const debounceMs = options.debounceMs ?? 300
const initialPerPage = options.initialPerPage ?? 10
const items = ref<T[]>([]) as { value: T[] }
const totalItems = ref(0)
const page = ref(1)
const perPage = ref(initialPerPage)
const filters = ref<Record<string, string | boolean | null>>({ ...initialFilters })
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// Token de generation : chaque reload incremente ce compteur. Quand
// une reponse arrive, on verifie que son token est toujours le plus
// recent — sinon on ignore (protection anti race condition si l'user
// tape vite plusieurs filtres).
let requestToken = 0
/**
* Construit le payload query params pour useApi.get.
* Les filtres a valeur vide (chaine vide, null) sont omis pour eviter
* de filtrer sur "rien" (comportement API Platform : filtre present
* avec valeur vide = ne retourne aucun resultat).
*/
function buildQueryParams(): Record<string, string | number | boolean> {
const params: Record<string, string | number | boolean> = {
page: page.value,
itemsPerPage: perPage.value,
}
for (const [key, value] of Object.entries(filters.value)) {
if (value === '' || value === null) continue
params[key] = value as string | boolean
}
return params
}
async function fetchItems(): Promise<void> {
const currentToken = ++requestToken
loading.value = true
try {
const data = await api.get<{ member: T[], totalItems: number }>(
endpoint,
buildQueryParams(),
{ toast: false },
)
// Ignore si une requete plus recente a ete lancee entre-temps.
if (currentToken !== requestToken) return
// Defensive : un mock/test ou une API mal configuree peut
// renvoyer undefined. On ne crash pas, on laisse les valeurs
// par defaut.
items.value = data?.member ?? []
totalItems.value = data?.totalItems ?? 0
} finally {
if (currentToken === requestToken) {
loading.value = false
}
}
}
/**
* Force un refetch immediat, sans debounce. Utile apres une mutation
* (POST/PATCH/DELETE) ou au mount initial.
*/
function reload(): void {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
void fetchItems()
}
/**
* Programme un refetch debounced. Utilise par le watcher de `filters`.
*/
function scheduleReload(): void {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
void fetchItems()
}, debounceMs)
}
// Watcher sur page/perPage : refetch immediat (pas de spam possible,
// l'user clique sur un bouton pagination).
watch([page, perPage], () => {
reload()
})
// Watcher sur filters : refetch debounced + reset page a 1 pour
// eviter l'etat "filtre qui reduit le total mais user reste sur une
// page inexistante".
watch(filters, () => {
if (page.value !== 1) {
page.value = 1
// Le changement de page declenchera son propre watcher, qui
// appellera reload(). Pas besoin d'en programmer un.
return
}
scheduleReload()
}, { deep: true })
return {
items,
totalItems,
page,
perPage,
filters,
loading,
reload,
}
}

View File

@@ -3,6 +3,24 @@ import type { UserData } from '~/shared/types/user-data'
import type { Site } from '~/shared/types/sites'
import { getCurrentUser, login, logout } from '~/shared/services/auth'
/**
* Callbacks enregistres par les composables singletons qui doivent
* reinitialiser leur etat quand la session est invalidee (ex: expiration
* JWT, logout depuis un intercepteur 401). Utilise le pattern
* "callback registration" (Option C) pour eviter une dependance croisee
* depuis shared/ vers modules/ — chaque composable s'auto-enregistre.
*/
const onSessionClearedCallbacks: Array<() => void> = []
/**
* Enregistre un callback a invoquer lorsque clearSession() est appelee.
* Typiquement invoque au setup-time du composable (module-level), donc
* une seule fois par instance de composable singleton.
*/
export function onAuthSessionCleared(cb: () => void): void {
onSessionClearedCallbacks.push(cb)
}
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as UserData | null,
@@ -17,6 +35,10 @@ export const useAuthStore = defineStore('auth', {
this.user = null
this.checked = true
this.isLoading = false
// Notifie les composables singletons (useCurrentSite, etc.) afin
// qu'ils reinitialisation leur etat — necessaire quand la session
// est invalidee par un intercepteur 401 sans passer par logout.vue.
onSessionClearedCallbacks.forEach((cb) => cb())
},
async ensureSession() {
if (this.checked) {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
@@ -62,6 +63,15 @@ use Symfony\Component\Validator\Constraints as Assert;
denormalizationContext: ['groups' => ['role:write']],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
// Filtres /admin/roles : recherche partielle insensible a la casse
// (ILIKE) sur label/code — un admin qui tape "ad" doit trouver
// "Administrateur". Les relations restent en exact (alimentees par un
// <select> cote front, donc casse maitrisee).
#[ApiFilter(SearchFilter::class, properties: [
'label' => 'ipartial',
'code' => 'ipartial',
'permissions.code' => 'exact',
])]
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
@@ -15,8 +18,14 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// Note architecture : User.php utilise SiteInterface (Shared) pour les
// type-hints afin de ne pas coupler le module Core au module Sites.
// La seule reference concrete a Site subsiste dans les metadonnees ORM
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -55,6 +64,18 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
],
denormalizationContext: ['groups' => ['user:write']],
)]
// Filtres /admin/users : recherche partielle insensible a la casse
// (ILIKE) sur username + filtre bool isAdmin + filtres exacts sur les
// relations (code de role ou nom de site).
// Les relations sont filtrees par jointure : `rbacRoles.code=admin` declenche
// un INNER JOIN user_role → role. `sites.name=Chatellerault` declenche
// INNER JOIN user_site → site.
#[ApiFilter(SearchFilter::class, properties: [
'username' => 'ipartial',
'rbacRoles.code' => 'exact',
'sites.name' => 'exact',
])]
#[ApiFilter(BooleanFilter::class, properties: ['isAdmin'])]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
@@ -112,17 +133,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/**
* Sites autorises pour l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToMany avec table de jointure `user_site`. Fetch EAGER
* pour la meme raison que `$rbacRoles` : garantir que `/api/me` et les
* voters futurs aient toujours la collection hydratee, meme dans un
* contexte de refresh JWT hors EntityManager. Le surcout SQL reste
* negligeable (≤ quelques sites par user en pratique).
* Relation ManyToMany avec table de jointure `user_site`. Fetch LAZY :
* le chargement est defere jusqu'a l'acces explicite a la collection.
* MeProvider (ou un futur provider avec JOIN FETCH) est responsable de
* precharger cette collection pour /api/me afin d'eviter N+1.
*
* Le groupe `user:list` a ete retire deliberement (securite : evite
* de fuiter la liste des sites de chaque user via GET /api/users).
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: Site::class, inversedBy: 'users', fetch: 'EAGER')]
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')]
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;
/**
@@ -136,11 +159,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant
* est enforce par UserRbacProcessor qui bascule automatiquement a `null`
* si le site courant est retire des sites autorises.
*
* Fetch LAZY : MeProvider (ou un futur provider avec JOIN FETCH) assure
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
* deliberement (securite : evite de fuiter le site actif via /api/users).
*/
#[ORM\ManyToOne(targetEntity: Site::class, fetch: 'EAGER')]
#[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list'])]
private ?Site $currentSite = null;
#[Groups(['me:read'])]
private ?SiteInterface $currentSite = null;
#[ORM\Column]
private ?string $password = null;
@@ -363,11 +390,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* Synchronise la collection inverse Site::$users en memoire pour eviter
* un etat incoherent entre les deux cotes de la M2M dans une meme
* session Doctrine (cf. ticket 2 review point #1).
*
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici.
*/
public function addSite(Site $site): static
public function addSite(SiteInterface $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
$site->addUser($this);
}
@@ -381,9 +412,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* par UserRbacProcessor (cote applicatif) ou doit etre maintenu
* explicitement par l'appelant. Voir Risque 2 du ticket 2 spec.
*/
public function removeSite(Site $site): static
public function removeSite(SiteInterface $site): static
{
if ($this->sites->removeElement($site)) {
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
$site->removeUser($this);
}
@@ -395,12 +427,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* collection autorisee, via comparaison d'identite d'objet Doctrine.
* Utilise par CurrentSiteProcessor pour valider un switch.
*/
public function hasSite(Site $site): bool
public function hasSite(SiteInterface $site): bool
{
return $this->sites->contains($site);
}
public function getCurrentSite(): ?Site
public function getCurrentSite(): ?SiteInterface
{
return $this->currentSite;
}
@@ -412,7 +444,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* "selectionner un site dans la liste autorisee", utiliser
* switchCurrentSite() qui porte la garde domaine.
*/
public function setCurrentSite(?Site $currentSite): static
public function setCurrentSite(?SiteInterface $currentSite): static
{
$this->currentSite = $currentSite;
@@ -427,7 +459,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*
* @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites
*/
public function switchCurrentSite(Site $site): void
public function switchCurrentSite(SiteInterface $site): void
{
if (!$this->hasSite($site)) {
throw SiteNotAuthorizedException::forSite($site);

View File

@@ -10,9 +10,11 @@ use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use LogicException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
@@ -29,14 +31,21 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
* dernier administrateur de l'instance, meme par un tiers. Enforce via
* AdminHeadcountGuardInterface.
* - Permission sites.manage : si le payload mute la collection `sites`,
* la permission `sites.manage` est requise en plus de `core.users.manage`.
* - Coherence currentSite (ticket 2 module Sites) : apres persist des
* sites autorises, si le `currentSite` n'est plus dans la collection,
* il est repositionne automatiquement :
* a) repasse a `null` s'il pointait vers un site retire ;
* b) est auto-selectionne sur le premier site de `sites` s'il etait
* null alors que la collection n'est pas vide (pratique pour un
* premier rattachement).
* null alors que la collection vient d'etre modifiee et n'est pas vide.
* Un second flush est emis uniquement si la coherence a du etre corrigee.
* La garde coherence est skippee si ni les sites ni le currentSite n'ont
* change (evite le silent site-switch sur un PATCH ne touchant pas aux sites).
*
* Atomicite : persistProcessor->process() + ensureCurrentSiteConsistency() sont
* executes dans une meme transaction wrapInTransaction pour eviter un etat
* partiellement persiste en cas d'erreur entre les deux flush.
*
* @implements ProcessorInterface<User, User>
*/
@@ -88,13 +97,51 @@ final class UserRbacProcessor implements ProcessorInterface
}
}
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Detection de la mutation de la collection `sites` avant tout flush.
// La collection est deja denormalisee dans $data quand process() est appele.
// On utilise PersistentCollection::isDirty() pour savoir si l'ORM a detecte
// une modification depuis le chargement initial (ajout/retrait d'elements).
$sitesCollection = $data->getSites();
$sitesWereMutated = $sitesCollection instanceof PersistentCollection
&& $sitesCollection->isDirty();
// Garde coherence currentSite (ticket 2 module Sites).
// Post-persist : le champ `sites` a ete applique par le persist processor.
// On s'assure que `currentSite` pointe toujours vers un site present
// dans la collection ou est recale automatiquement.
$this->ensureCurrentSiteConsistency($data);
// Capture de l'ID du currentSite avant persist pour la detection post-flush.
$originalCurrentSiteId = $data->getCurrentSite()?->getId();
// Garde sites.manage : la modification de la collection de sites rattaches
// a un user est une operation sensible qui requiert une permission distincte
// de core.users.manage (evite le bypass de sites.manage via /rbac).
if ($sitesWereMutated && !$this->security->isGranted('sites.manage')) {
throw new AccessDeniedHttpException(
'La modification des sites rattaches a un user requiert la permission sites.manage.'
);
}
// Persistance + correction de coherence currentSite dans une seule transaction.
// wrapInTransaction rollback automatiquement sur toute exception et la re-lance,
// ce qui preserve le comportement attendu pour BadRequestHttpException.
$result = null;
$this->entityManager->wrapInTransaction(function () use (
$data,
$operation,
$uriVariables,
$context,
$sitesWereMutated,
$originalCurrentSiteId,
&$result,
): void {
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Garde coherence currentSite (ticket 2 module Sites).
// Post-persist : le champ `sites` a ete applique par le persist processor.
// On s'assure que `currentSite` pointe toujours vers un site present
// dans la collection ou est recale automatiquement — mais UNIQUEMENT si
// les sites ou le currentSite ont effectivement ete touches dans ce PATCH.
$currentSiteChangedByPersist = $originalCurrentSiteId !== $data->getCurrentSite()?->getId();
if ($sitesWereMutated || $currentSiteChangedByPersist) {
$this->ensureCurrentSiteConsistency($data);
}
});
return $result;
}
@@ -104,11 +151,14 @@ final class UserRbacProcessor implements ProcessorInterface
* - si l'actuel n'est plus dans `sites` apres update → repasse a null ;
* - si null et `sites` non vide → auto-selectionne le premier site
* (coherent avec le choix de ne jamais laisser un user rattache a
* plusieurs sites sans contexte courant).
* plusieurs sites sans contexte courant apres une mutation effective).
*
* N'emet un flush additionnel que si une correction a ete necessaire :
* pas de cout DB sur la majorite des requetes /rbac qui ne touchent pas
* aux sites.
*
* Cette methode ne doit etre appelee que si les sites ont reellement
* ete mutes dans la requete courante (voir logique dans process()).
*/
private function ensureCurrentSiteConsistency(User $user): void
{

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
@@ -12,6 +14,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -61,12 +64,21 @@ use Symfony\Component\Validator\Constraints as Assert;
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
)]
// Filtres cote API pour /admin/sites : recherche partielle insensible a
// la casse (SQL ILIKE %x%) sur les champs texte saisis dans les headers
// de la DataTable. postalCode est purement numerique donc le I/partial
// donne le meme resultat, mais on reste coherent avec name/city.
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'ipartial',
'city' => 'ipartial',
'postalCode' => 'ipartial',
])]
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
#[ORM\Table(name: 'site')]
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
class Site
class Site implements SiteInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]

View File

@@ -4,24 +4,15 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Exception;
use App\Module\Sites\Domain\Entity\Site;
use DomainException;
use App\Shared\Domain\Exception\SiteNotAuthorizedException as SharedSiteNotAuthorizedException;
/**
* Levee lorsqu'un utilisateur tente de selectionner comme site courant un
* site qui ne fait pas partie de ses sites autorises.
* Alias de retrocompatibilite vers Shared\Domain\Exception\SiteNotAuthorizedException.
*
* Exception purement domaine : la traduction HTTP (403) est faite par le
* CurrentSiteProcessor via try/catch, aligne sur le pattern
* SystemRoleDeletionException du module Core.
* La classe canonique a ete deplacee dans Shared pour rompre le couplage
* Core → Sites. Les consommateurs existants dans le module Sites
* (CurrentSiteProcessor) continuent de l'attraper ici sans modification.
*
* @see SharedSiteNotAuthorizedException
*/
final class SiteNotAuthorizedException extends DomainException
{
public static function forSite(Site $site): self
{
return new self(sprintf(
'Le site "%s" ne fait pas partie de vos sites autorises.',
$site->getName(),
));
}
}
final class SiteNotAuthorizedException extends SharedSiteNotAuthorizedException {}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
use function sprintf;
/**
* Extension API Platform qui restreint les collections et items de la
* resource Site (/api/sites) aux seuls sites auxquels l'utilisateur
* authentifie est rattache (ticket module Sites — prevention de la fuite
* de donnees cross-tenant).
*
* `Site` n'implemente pas `SiteAwareInterface` (ce serait circulaire : un
* site ne s'appartient pas a lui-meme). Cette extension complementaire
* cible specifiquement `Site::class` et applique un filtre IN sur les IDs
* des sites de l'utilisateur.
*
* Comportement selon les cas :
* - resource != Site::class → no-op (les autres resources sont
* gerees par SiteScopedQueryExtension) ;
* - is_granted('sites.bypass_scope') → pas de filtre (admin / bypass) ;
* - user non authentifie → no-op (API Platform renvoie 401 avant) ;
* - user sans aucun site → WHERE 1 = 0 (aucun acces) ;
* - cas normal → WHERE site.id IN (:allowedSites).
*
* Consequence anti-enumeration : GET /api/sites/{id} retourne 404 et non
* 403 si l'item existe mais n'appartient pas aux sites de l'utilisateur
* (comportement natif API Platform quand Doctrine retourne null).
*/
final class SiteCollectionScopedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private readonly Security $security,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
}
/**
* Applique le filtre IN sur les IDs de sites autorises si les conditions
* d'application sont remplies. No-op sinon.
*/
private function applyScope(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
): void {
// 1) Cette extension cible uniquement la resource Site.
if (Site::class !== $resourceClass) {
return;
}
// 2) Admin ou user avec bypass explicite : visibilite globale.
if ($this->security->isGranted('sites.bypass_scope')) {
return;
}
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
$user = $this->security->getUser();
if (!$user instanceof User) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
// 4) User sans aucun site rattache -> aucun acces possible.
$siteIds = $user->getSites()->map(fn (Site $s) => $s->getId())->toArray();
if (empty($siteIds)) {
$queryBuilder->andWhere('1 = 0');
return;
}
// 5) Cas normal : restriction aux sites autorises de l'utilisateur.
$param = $queryNameGenerator->generateParameterName('allowedSites');
$queryBuilder
->andWhere(sprintf('%s.id IN (:%s)', $rootAlias, $param))
->setParameter($param, $siteIds)
;
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
use function sprintf;
/**
* Extension API Platform qui restreint /api/users (collection + item) aux
* utilisateurs partageant au moins un site commun avec l'appelant.
*
* Objectif : empecher l'enumeration cross-site des utilisateurs. Sans ce
* filtre, un user du site A pourrait lister tous les users du site B via
* GET /api/users.
*
* Conditions de bypass :
* - is_granted('sites.bypass_scope') → visibilite totale (admin ou bypass
* explicite) ;
* - user non authentifie → no-op (API Platform renvoie 401) ;
*
* Cas particulier — appelant sans aucun site rattache :
* Comportement defensif : l'utilisateur ne voit que lui-meme. Cela evite
* de bloquer completement un user mal configure tout en ne lui revelant
* aucun autre utilisateur.
*
* Strategie DQL : JOIN sur la relation ManyToMany `.sites` + DISTINCT pour
* eviter les doublons si un user partage plusieurs sites avec l'appelant.
* Le alias `s_scope` est utilise pour la jointure intermediaire.
*/
final class UserSiteScopedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private readonly Security $security,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
}
/**
* Applique le filtre de partage de site si les conditions d'application
* sont remplies. No-op sinon.
*/
private function applyScope(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
): void {
// 1) Cette extension cible uniquement la resource User.
if (User::class !== $resourceClass) {
return;
}
// 2) Admin ou bypass explicite : visibilite totale.
if ($this->security->isGranted('sites.bypass_scope')) {
return;
}
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
$user = $this->security->getUser();
if (!$user instanceof User) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$callerSiteIds = $user->getSites()->map(fn (Site $s) => $s->getId())->toArray();
// 4) Appelant sans site : comportement defensif -> il ne voit que lui-meme.
if (empty($callerSiteIds)) {
$queryBuilder
->andWhere(sprintf('%s.id = :self', $rootAlias))
->setParameter('self', $user->getId())
;
return;
}
// 5) Cas normal : garder uniquement les users qui partagent au moins
// un site avec l'appelant. JOIN sur la relation ManyToMany `.sites`
// + filtre IN + DISTINCT pour eviter les lignes dupliquees.
$param = $queryNameGenerator->generateParameterName('callerSites');
$queryBuilder
->innerJoin(sprintf('%s.sites', $rootAlias), 's_scope')
->andWhere(sprintf('s_scope.id IN (:%s)', $param))
->setParameter($param, $callerSiteIds)
->distinct()
;
}
}

View File

@@ -10,6 +10,7 @@ use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use App\Module\Sites\Infrastructure\ApiPlatform\Resource\CurrentSiteResource;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\OptimisticLockException;
use LogicException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -21,11 +22,17 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
* Flux :
* 1. Recupere l'user authentifie via Security.
* 2. Extrait le site cible depuis la ressource denormalisee.
* 3. Valide que le site fait partie des `sites` de l'user — sinon leve
* 3. Refresh de l'user depuis la BDD pour eliminer la race condition TOCTOU :
* si un autre thread a revoque le site entre le chargement de session et
* ce PATCH, le refresh garantit que hasSite() reflete l'etat reel en base.
* 4. Valide que le site fait partie des `sites` de l'user — sinon leve
* SiteNotAuthorizedException convertie immediatement en 403.
* 4. Positionne `currentSite`, flush, retourne l'user pour normalisation
* 5. Positionne `currentSite`, flush, retourne l'user pour normalisation
* par API Platform via les groupes `me:read` (payload identique a /api/me).
*
* Les etapes 3-5 sont executees dans une meme transaction pour garantir
* un rollback propre en cas d'erreur entre le refresh et le flush.
*
* @implements ProcessorInterface<CurrentSiteResource, User>
*/
final class CurrentSiteProcessor implements ProcessorInterface
@@ -57,15 +64,35 @@ final class CurrentSiteProcessor implements ProcessorInterface
throw new BadRequestHttpException('Le champ "site" est requis.');
}
// Refresh + switchCurrentSite + flush dans une transaction atomique.
// Le refresh elimine la race condition TOCTOU : si un PATCH /rbac concurrent
// a revoque le site de l'user entre le chargement de session et ici, le
// refresh force un re-fetch de l'user et de sa collection de sites depuis
// la BDD, garantissant que hasSite() reflete l'etat reel persisté.
try {
$user->switchCurrentSite($targetSite);
} catch (SiteNotAuthorizedException $e) {
// Traduction HTTP immediate (pas de listener kernel necessaire) :
// aligne sur le pattern RoleProcessor → SystemRoleDeletionException.
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->entityManager->wrapInTransaction(function () use ($user, $targetSite): void {
// Re-fetch de l'user + ses collections depuis la BDD (elimination TOCTOU).
$this->entityManager->refresh($user);
$this->entityManager->flush();
try {
$user->switchCurrentSite($targetSite);
} catch (SiteNotAuthorizedException $e) {
// Traduction HTTP immediate (pas de listener kernel necessaire) :
// aligne sur le pattern RoleProcessor → SystemRoleDeletionException.
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->entityManager->flush();
});
} catch (OptimisticLockException $e) {
// Protection future : si un champ @Version est ajoute sur User,
// le conflit de version sera intercepte ici plutot que de remonter
// comme une erreur generique.
throw new BadRequestHttpException(
'Conflit de version detecte lors du changement de site courant. Veuillez reessayer.',
$e,
);
}
return $user;
}

View File

@@ -6,9 +6,13 @@ namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteAwareInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
@@ -23,8 +27,11 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
*
* Comportement :
* - $data pas SiteAware -> delegation directe (no-op).
* - $data SiteAware avec site deja positionne -> delegation directe
* (l'admin qui envoie un site explicite garde ce site).
* - $data SiteAware avec site deja positionne, appelant a `sites.bypass_scope`
* -> delegation directe (ex: admin qui cree une entite dans un autre site).
* - $data SiteAware avec site deja positionne, appelant SANS `sites.bypass_scope`
* -> validation que le site precise appartient aux sites autorises de l'user.
* Si non, leve AccessDeniedHttpException (cross-site write interdite).
* - $data SiteAware sans site, provider retourne un Site -> injection
* puis delegation.
* - $data SiteAware sans site, provider retourne null -> throw 400
@@ -43,20 +50,43 @@ final class SiteAwareInjectionProcessor implements ProcessorInterface
public function __construct(
private readonly ProcessorInterface $inner,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
private readonly Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($data instanceof SiteAwareInterface && null === $data->getSite()) {
$currentSite = $this->currentSiteProvider->get();
if ($data instanceof SiteAwareInterface) {
if (null !== $data->getSite()) {
// Le payload precise un site explicite : on valide que le site
// appartient aux sites autorises de l'utilisateur courant, sauf
// si celui-ci dispose de la permission `sites.bypass_scope`
// (ex: admin effectuant une operation cross-site).
if (!$this->security->isGranted('sites.bypass_scope')) {
$user = $this->security->getUser();
$explicitSite = $data->getSite();
// hasSite() attend un Site concret. Si l'agent entity fait
// evoluer la signature vers SiteInterface, le instanceof
// reste valide (Site implemente SiteInterface) et le cast
// disparaitra naturellement lors du prochain nettoyage.
if ($user instanceof User && $explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
throw new AccessDeniedHttpException(
'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.'
);
}
}
} else {
// Aucun site dans le payload : injection automatique depuis le
// site courant de l'utilisateur.
$currentSite = $this->currentSiteProvider->get();
if (null === $currentSite) {
throw new BadRequestHttpException(
'Impossible de creer l\'enregistrement : aucun site selectionne.',
);
if (null === $currentSite) {
throw new BadRequestHttpException(
'Impossible de creer l\'enregistrement : aucun site selectionne.',
);
}
$data->setSite($currentSite);
}
$data->setSite($currentSite);
}
return $this->inner->process($data, $operation, $uriVariables, $context);

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use App\Module\Sites\Domain\Entity\Site;
/**
* Contrat opt-in pour les entites dont la visibilite est scopee par site.
*
@@ -16,9 +14,13 @@ use App\Module\Sites\Domain\Entity\Site;
* si le payload ne precise pas de site.
*
* L'implementation concrete doit :
* - Declarer une relation ManyToOne(Site::class) avec colonne `site_id` NOT NULL.
* - Declarer une relation ManyToOne vers l'entite concrete Site avec colonne
* `site_id` NOT NULL (targetEntity: \App\Module\Sites\Domain\Entity\Site).
* - Indexer `site_id` en base (sinon le filtre WHERE genere un full-scan).
*
* Les signatures utilisent SiteInterface (et non la classe concrete Site)
* pour que Shared n'importe pas directement le module Sites.
*
* Ne PAS implementer cette interface pour :
* - Des entites globales (catalogue partage, roles, permissions, users).
* - Des entites dont le scope est "par tenant" plus large que le site
@@ -29,7 +31,7 @@ use App\Module\Sites\Domain\Entity\Site;
*/
interface SiteAwareInterface
{
public function getSite(): ?Site;
public function getSite(): ?SiteInterface;
public function setSite(Site $site): void;
public function setSite(SiteInterface $site): void;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Interface minimale exposant ce que le noyau (Shared/Core) doit connaitre
* d'un Site, sans creer de couplage direct vers le module Sites.
*
* Implemente par App\Module\Sites\Domain\Entity\Site.
* Utilisee comme type-hint dans SiteAwareInterface, User et toute entite
* Shared/Core qui manipule un site sans avoir besoin des details metier.
*/
interface SiteInterface
{
public function getId(): ?int;
public function getName(): ?string;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
use App\Shared\Domain\Contract\SiteInterface;
use DomainException;
/**
* Levee lorsqu'un utilisateur tente de selectionner comme site courant un
* site qui ne fait pas partie de ses sites autorises.
*
* Exception purement domaine : la traduction HTTP (403) est faite par le
* CurrentSiteProcessor via try/catch, aligne sur le pattern
* SystemRoleDeletionException du module Core.
*
* Deplacee dans Shared/Domain/Exception/ pour eviter que le module Core
* n'importe directement depuis le module Sites (violation du principe de
* non-couplage inter-modules).
*/
class SiteNotAuthorizedException extends DomainException
{
public static function forSite(SiteInterface $site): self
{
return new self(sprintf(
'Le site "%s" ne fait pas partie de vos sites autorises.',
$site->getName(),
));
}
}

View File

@@ -6,7 +6,9 @@ namespace App\Tests\Fixtures\SiteAware;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteAwareInterface;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
/**
* Entite fictive utilisee UNIQUEMENT en tests (ticket 4 module Sites).
@@ -57,13 +59,16 @@ class FakeSiteAwareEntity implements SiteAwareInterface
$this->name = $name;
}
public function getSite(): ?Site
public function getSite(): ?SiteInterface
{
return $this->site;
}
public function setSite(Site $site): void
public function setSite(SiteInterface $site): void
{
if (!$site instanceof Site) {
throw new InvalidArgumentException('FakeSiteAwareEntity requires a concrete Site (Doctrine ManyToOne target).');
}
$this->site = $site;
}
}

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -123,6 +124,19 @@ abstract class AbstractApiTestCase extends ApiTestCase
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, $password));
$user->addRbacRole($role);
// Le helper attache le user jetable a tous les sites existants pour
// neutraliser le filtrage par UserSiteScopedExtension : la plupart
// des tests assume une visibilite globale sur les users cibles. Les
// tests qui valident le comportement "sans sites" doivent creer leur
// user a la main (pas via ce helper).
$siteRepository = $em->getRepository(Site::class);
if (null !== $siteRepository) {
foreach ($siteRepository->findAll() as $site) {
$user->addSite($site);
}
}
$em->persist($user);
$em->flush();

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Api;
/**
* Tests fonctionnels des ApiFilter ajoutes sur User, Role et Site pour
* les DataTables admin (filtrage serveur + pagination negociee).
*
* Ces tests s'appuient uniquement sur les fixtures (admin, alice, bob +
* 3 sites + 2 roles systeme + 6 permissions) — aucune mutation entre
* tests, pas de cleanup necessaire.
*
* @internal
*/
final class AdminFiltersApiTest extends AbstractApiTestCase
{
// ========================================================================
// User filters
// ========================================================================
public function testUsersFilterByUsernamePartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/users?username=ali');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame(1, $data['totalItems']);
self::assertSame('alice', $data['member'][0]['username']);
}
public function testUsersFilterByIsAdminTrueReturnsOnlyAdmins(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/users?isAdmin=true');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertGreaterThanOrEqual(1, $data['totalItems']);
foreach ($data['member'] as $user) {
self::assertTrue($user['isAdmin']);
}
}
public function testUsersFilterByIsAdminFalseExcludesAdmins(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/users?isAdmin=false');
$data = $response->toArray();
foreach ($data['member'] as $user) {
self::assertFalse($user['isAdmin']);
}
}
public function testUsersFilterBySiteNameReturnsUsersOfThatSite(): void
{
// alice est rattachee a Chatellerault uniquement, bob a Saint-Jean.
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/users?sites.name=Saint-Jean');
$data = $response->toArray();
$usernames = array_column($data['member'], 'username');
self::assertContains('admin', $usernames);
self::assertContains('bob', $usernames);
self::assertNotContains('alice', $usernames);
}
public function testUsersFilterByRoleCodeReturnsUsersWithThatRole(): void
{
// admin porte le role systeme 'admin', alice/bob portent 'user'.
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/users?rbacRoles.code=admin');
$data = $response->toArray();
$usernames = array_column($data['member'], 'username');
self::assertContains('admin', $usernames);
self::assertNotContains('alice', $usernames);
}
// ========================================================================
// Site filters
// ========================================================================
public function testSitesFilterByNamePartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/sites?name=Chat');
$data = $response->toArray();
self::assertSame(1, $data['totalItems']);
self::assertSame('Chatellerault', $data['member'][0]['name']);
}
public function testSitesFilterByCityPartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
// Fontenet est la ville du site Saint-Jean.
$response = $client->request('GET', '/api/sites?city=Fonten');
$data = $response->toArray();
self::assertSame(1, $data['totalItems']);
self::assertSame('Saint-Jean', $data['member'][0]['name']);
}
public function testSitesFilterByPostalCodePartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/sites?postalCode=82');
$data = $response->toArray();
self::assertSame(1, $data['totalItems']);
self::assertSame('Pommevic', $data['member'][0]['name']);
}
// ========================================================================
// Role filters
// ========================================================================
public function testRolesFilterByLabelPartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles?label=Admin');
$data = $response->toArray();
self::assertGreaterThanOrEqual(1, $data['totalItems']);
foreach ($data['member'] as $role) {
self::assertStringContainsStringIgnoringCase('admin', $role['label']);
}
}
public function testRolesFilterByLabelIsCaseInsensitive(): void
{
// Garde explicite : la strategy est `ipartial` (ILIKE) et pas
// `partial` (LIKE). Chercher "ad" en minuscules DOIT trouver
// "Administrateur" (A majuscule). Si un futur dev retombe en
// strategy `partial` par megarde, ce test cassera.
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles?label=ad');
$data = $response->toArray();
$labels = array_column($data['member'], 'label');
self::assertContains('Administrateur', $labels);
}
public function testRolesFilterByCodePartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles?code=user');
$data = $response->toArray();
self::assertGreaterThanOrEqual(1, $data['totalItems']);
foreach ($data['member'] as $role) {
self::assertStringContainsString('user', $role['code']);
}
}
public function testRolesFilterByIsSystemTrueReturnsOnlySystemRoles(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles?isSystem=true');
$data = $response->toArray();
self::assertGreaterThanOrEqual(2, $data['totalItems']);
foreach ($data['member'] as $role) {
self::assertTrue($role['isSystem']);
}
}
public function testRolesFilterByPermissionCodeReturnsRolesWithThatPermission(): void
{
// Le role systeme 'admin' a le flag isAdmin qui bypass toutes les
// permissions — il n'a pas necessairement des permissions explicites.
// On teste donc avec la permission sites.view qui devrait exister
// mais potentiellement n'etre sur aucun role custom. Le filtre
// fonctionne techniquement meme sur un resultat vide.
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles?permissions.code=sites.view');
self::assertResponseIsSuccessful();
$data = $response->toArray();
// On valide juste que la requete est acceptee (200) et que le
// filtre transforme bien l'IRI en JOIN — nombre de resultats
// depend de l'etat des fixtures.
self::assertArrayHasKey('totalItems', $data);
}
// ========================================================================
// Pagination
// ========================================================================
public function testPaginationWithItemsPerPageReducesMember(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/sites?itemsPerPage=2');
$data = $response->toArray();
self::assertLessThanOrEqual(2, count($data['member']));
// totalItems reflete le TOTAL pas la page courante.
self::assertGreaterThanOrEqual(3, $data['totalItems']);
}
public function testPaginationPage2SkipsFirstItems(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$page1 = $client->request('GET', '/api/sites?itemsPerPage=1&page=1')->toArray();
$page2 = $client->request('GET', '/api/sites?itemsPerPage=1&page=2')->toArray();
self::assertCount(1, $page1['member']);
self::assertCount(1, $page2['member']);
self::assertNotSame(
$page1['member'][0]['id'],
$page2['member'][0]['id'],
'Les items de la page 2 doivent differer de ceux de la page 1.',
);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Tests\Module\Core\Api;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
@@ -41,11 +42,18 @@ final class UserRbacApiTest extends AbstractApiTestCase
/** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
// User cible standard (non admin).
// User cible standard (non admin). On lui attache tous les sites
// fixtures pour rester visible depuis les callers non-admin munis de
// sites (cf. UserSiteScopedExtension qui filtre `/api/users` par
// intersection de sites). Sans cela, un user `core.users.manage`
// sans site commun avec test_target recevrait un 404 sur le PATCH.
$target = new User();
$target->setUsername('test_target');
$target->setIsAdmin(false);
$target->setPassword($hasher->hashPassword($target, 'secret'));
foreach ($em->getRepository(Site::class)->findAll() as $site) {
$target->addSite($site);
}
$em->persist($target);
// User admin dedie pour le cas d'auto-suicide (pas l'admin fixture).

View File

@@ -50,6 +50,14 @@ final class UserRbacProcessorTest extends TestCase
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
// wrapInTransaction doit executer reellement la closure pour que le
// resultat de persistProcessor->process() soit capture dans $result.
// Sans ce stub, la closure n'est jamais invoquee et $result reste null.
$this->entityManager
->method('wrapInTransaction')
->willReturnCallback(static fn (callable $fn) => $fn())
;
$this->processor = new UserRbacProcessor(
$this->persistProcessor,
$this->entityManager,

View File

@@ -39,7 +39,14 @@ final class CurrentSiteSwitchApiTest extends AbstractApiTestCase
public function testUserCannotSwitchToUnauthorizedSite(): void
{
// alice n'a que Chatellerault. Tenter Pommevic → 403.
// alice n'a que Chatellerault. Tenter Pommevic → 400 (anti-enumeration).
//
// Depuis l'ajout de SiteCollectionScopedExtension, les sites hors
// du scope de l'user sont filtres a la source : l'IriConverter ne
// peut pas resoudre `/api/sites/{id}` pour un site non autorise et
// leve 400 "Item not found". Reponse identique a "site inexistant",
// ce qui empeche l'enumeration des ids de sites tiers. Avant la PR
// scope, le processor traduisait SiteNotAuthorizedException → 403.
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
self::assertNotNull($pommevic);
@@ -50,7 +57,7 @@ final class CurrentSiteSwitchApiTest extends AbstractApiTestCase
'json' => ['site' => '/api/sites/'.$pommevic->getId()],
]);
self::assertResponseStatusCodeSame(403);
self::assertResponseStatusCodeSame(400);
}
public function testSwitchWithMissingSiteFieldReturns400(): void

View File

@@ -11,8 +11,10 @@ use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\SiteAwareInjectionProcessor;
use App\Shared\Domain\Contract\SiteAwareInterface;
use App\Shared\Domain\Contract\SiteInterface;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
@@ -125,20 +127,27 @@ final class SiteAwareInjectionProcessorTest extends TestCase
$provider = $this->createStub(CurrentSiteProviderInterface::class);
$provider->method('get')->willReturn($currentSite);
return new SiteAwareInjectionProcessor($inner, $provider);
// Stub Security : bypass_scope = true par defaut pour preserver le
// comportement des tests historiques (pas de validation cross-site).
// Les tests dedies a la validation cross-site instancient leur propre
// Security via un helper dedie.
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturn(true);
return new SiteAwareInjectionProcessor($inner, $provider, $security);
}
private function makeSiteAwareStub(?Site $initialSite): SiteAwareInterface
{
return new class($initialSite) implements SiteAwareInterface {
public function __construct(private ?Site $site) {}
public function __construct(private ?SiteInterface $site) {}
public function getSite(): ?Site
public function getSite(): ?SiteInterface
{
return $this->site;
}
public function setSite(Site $site): void
public function setSite(SiteInterface $site): void
{
$this->site = $site;
}