feat(sites) : API CRUD + rattachement User<->Site + admin (ticket 2/4)

Exposition de Site via API Platform (5 operations RBAC sites.view/sites.manage),
relation User.sites (M2M user_site EAGER) + User.currentSite (M2O nullable,
ON DELETE SET NULL). Endpoint PATCH /api/me/current-site via ressource
virtuelle + processor (SiteNotAuthorizedException → 403). UserRbacProcessor
etendu avec gardes post-persist : auto-reset si currentSite retire, auto-select
premier site si null + sites non vide.

Page /admin/sites (DataTable + drawer creation/edition + modale suppression).
UserRbacDrawer etendu avec section "Sites autorises". Colonne "Sites" ajoutee
dans la table /admin/users (liste des noms separes par virgule). Sidebar
entree Sites (module: sites, permission: sites.view).

Refactor adresse : split full_address en street + complement (nullable) + getter
computed Site::getFullAddress() multi-lignes. Migration ALTER dediee pour
compat devs ayant deja joue le ticket 1. Fixtures avec vraies adresses
(Chatellerault/Fontenet/Pommevic).

Doctrine : inversedBy synchrone User.sites <-> Site.users pour maintenir la
collection inverse en memoire. User::switchCurrentSite() porte la garde
domaine (throw SiteNotAuthorizedException), aligne sur Role::ensureDeletable.
Helper skipIfSitesModuleDisabled centralise dans AbstractApiTestCase.

Tests : 182/182 (182/182 aussi module desactive, 2 skipped). 29 nouveaux tests
PHPUnit (CRUD API, switch currentSite, cascade DB, /api/me enrichi, extension
/rbac, gardes structurelles fullAddress/currentSite ignores, anti-cycle
Site.users). 11 tests Vitest sur la validation hex couleur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 10:09:05 +02:00
parent 105574ba2f
commit d137828919
32 changed files with 2271 additions and 117 deletions

View File

@@ -64,6 +64,27 @@
</div>
</div>
<!-- Section Sites autorises (ticket 2 module Sites) -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.sitesSection') }}
</h4>
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
{{ t('admin.sites.noSites') }}
</div>
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="site in allSites"
:id="`site-${site.id}`"
:key="site.id"
:label="site.name"
:model-value="selectedSiteIds.has(site.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => toggleSite(site.id, val)"
/>
</div>
</div>
<!-- Section Resume permissions effectives -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
@@ -92,6 +113,7 @@
<script setup lang="ts">
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
interface PermissionModule {
module: string
@@ -115,10 +137,12 @@ const emit = defineEmits<{
const saving = ref(false)
const allRoles = ref<Role[]>([])
const allPermissions = ref<Permission[]>([])
const allSites = ref<Site[]>([])
const form = ref({ isAdmin: false })
const selectedRoleIds = ref(new Set<number>())
const selectedDirectPermissionIds = ref(new Set<number>())
const selectedSiteIds = ref(new Set<number>())
// Detecter l'auto-edition
const isSelfEdit = computed(() => props.user?.id === auth.user?.id)
@@ -182,14 +206,17 @@ const effectivePermissions = computed<EffectivePermission[]>(() => {
.sort((a, b) => a.code.localeCompare(b.code))
})
// Charger roles et permissions
// Charger roles, permissions et sites en parallele pour minimiser le TTFB
// a l'ouverture du drawer.
async function loadData() {
const [rolesData, permsData] = await Promise.all([
const [rolesData, permsData, sitesData] = await Promise.all([
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
])
allRoles.value = rolesData.member
allPermissions.value = permsData.member
allSites.value = sitesData.member
}
// Remplir le formulaire quand le user change
@@ -198,10 +225,12 @@ watch(() => props.user, (user) => {
form.value.isAdmin = user.isAdmin
selectedRoleIds.value = new Set(user.roles.map(iriToId))
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
selectedSiteIds.value = new Set((user.sites ?? []).map(iriToId))
} else {
form.value.isAdmin = false
selectedRoleIds.value = new Set()
selectedDirectPermissionIds.value = new Set()
selectedSiteIds.value = new Set()
}
}, { immediate: true })
@@ -235,6 +264,13 @@ function handleToggleAll(module: string, selected: boolean) {
selectedDirectPermissionIds.value = ids
}
function toggleSite(id: number, selected: boolean) {
const ids = new Set(selectedSiteIds.value)
if (selected) ids.add(id)
else ids.delete(id)
selectedSiteIds.value = ids
}
async function handleSave() {
if (!props.user) return
saving.value = true
@@ -243,6 +279,7 @@ async function handleSave() {
isAdmin: form.value.isAdmin,
roles: Array.from(selectedRoleIds.value).map(id => `/api/roles/${id}`),
directPermissions: Array.from(selectedDirectPermissionIds.value).map(id => `/api/permissions/${id}`),
sites: Array.from(selectedSiteIds.value).map(id => `/api/sites/${id}`),
}, {
toastSuccessMessage: t('admin.users.toast.updated'),
})