fix(review) : resout findings 3e passe review (HIGH frontend + MEDIUMs backend/frontend/E2E)

Backend :
- AuditLogWriter::stripSensitive rendu reellement recursif (matche doc).
- Tests GET /api/permissions/{id} non-admin pour chaque branche OR (gap Codex).
- Gardes non-regression UserRbacProcessor : PATCH /rbac sans clef sites ne
  doit ni auto-selectionner currentSite ni exiger sites.manage.

Frontend :
- useAuditLog : renomme export trompeur fetchLogs -> fetchLogsCached, le
  nom reflete desormais le comportement (cache pollue sinon).
- RoleDrawer / UserRbacDrawer : catch explicite + message d'erreur +
  bouton save disabled si le chargement des referentiels a echoue (evite
  un ecrasement silencieux des droits).
- AuditTimeline / AuditLogDetail : `oui`/`non` passent par common.yes/no.
- AuditTimeline : Intl.RelativeTimeFormat et toLocaleString suivent la
  locale i18n courante (plus de hardcode 'fr').

E2E :
- sidebar-visibility.spec : remplace waitForLoadState('networkidle')
  fragile par attente semantique sur accountDashboardLink (stable en CI).

Tests : 237/237 green, eslint clean, php-cs-fixer clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-04-23 10:31:03 +02:00
parent 25cd6a1ecc
commit b1255bb57a
12 changed files with 271 additions and 48 deletions

View File

@@ -33,7 +33,15 @@
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.roles.form.permissions') }}
</h4>
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
<!-- Etat d'erreur explicite : sans ce message, un drawer vide
ressemblerait a un role legitimement sans permissions. -->
<div
v-if="permissionsLoadFailed"
class="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700"
>
{{ t('admin.roles.permissions.loadFailed') }}
</div>
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
{{ t('admin.roles.permissions.noPermissions') }}
</div>
<div class="flex flex-col gap-4">
@@ -70,7 +78,7 @@
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving"
:disabled="saving || permissionsLoadFailed"
@click="handleSave"
/>
</div>
@@ -102,6 +110,11 @@ const emit = defineEmits<{
const saving = ref(false)
const allPermissions = ref<Permission[]>([])
// Signale un echec de chargement du catalogue de permissions : on bloque
// alors la sauvegarde pour eviter qu'un drawer ouvert avec zero permission
// visible (cas d'un 403 ou d'une panne reseau) n'ecrase silencieusement
// toutes les permissions du role.
const permissionsLoadFailed = ref(false)
const form = ref({
label: '',
@@ -129,12 +142,21 @@ const permissionsByModule = computed<PermissionModule[]>(() => {
// Charger les permissions au montage
async function loadPermissions() {
const data = await api.get<{ member: Permission[] }>(
'/permissions',
{ 'orphan': false, itemsPerPage: 999 },
{ toast: false },
)
allPermissions.value = data.member
permissionsLoadFailed.value = false
try {
const data = await api.get<{ member: Permission[] }>(
'/permissions',
{ 'orphan': false, itemsPerPage: 999 },
// `toast: true` : en cas d'echec (403, reseau, 500), l'utilisateur
// voit l'erreur remonter. Sans ce feedback, un catalogue vide
// ressemblerait a un role sans permissions disponibles.
{ toast: true },
)
allPermissions.value = data.member
} catch {
allPermissions.value = []
permissionsLoadFailed.value = true
}
}
// Remplir le formulaire quand le role change

View File

@@ -6,6 +6,16 @@
@update:model-value="emit('update:modelValue', $event)"
>
<div class="flex flex-col gap-6 p-4">
<!-- Etat d'erreur de chargement des referentiels : bloque la
sauvegarde pour empecher un ecrasement silencieux des droits. -->
<div
v-if="loadFailed"
class="flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800"
>
<Icon name="mdi:alert-circle-outline" class="size-5 shrink-0" />
{{ t('admin.users.drawer.loadFailed') }}
</div>
<!-- Avertissement auto-edition -->
<div
v-if="isSelfEdit"
@@ -103,7 +113,7 @@
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving"
:disabled="saving || loadFailed"
@click="handleSave"
/>
</div>
@@ -138,6 +148,10 @@ const saving = ref(false)
const allRoles = ref<Role[]>([])
const allPermissions = ref<Permission[]>([])
const allSites = ref<Site[]>([])
// Signale un echec de chargement des referentiels : on bloque alors la
// sauvegarde pour eviter qu'un drawer ouvert sans donnees (403, reseau)
// n'ecrase silencieusement l'etat RBAC du user (vidage roles/permissions/sites).
const loadFailed = ref(false)
const form = ref({ isAdmin: false })
const selectedRoleIds = ref(new Set<number>())
@@ -211,20 +225,32 @@ const effectivePermissions = computed<EffectivePermission[]>(() => {
// Le detail RBAC est la seule source de verite pour l'etat initial du formulaire :
// props.user vient de la liste /api/users qui n'expose pas les sites (groupe leger).
async function loadData(userId: number) {
const [rolesData, permsData, sitesData, userRbac] = 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 }),
api.get<UserRbacDetail>(`/users/${userId}/rbac`, {}, { toast: false }),
])
allRoles.value = rolesData.member
allPermissions.value = permsData.member
allSites.value = sitesData.member
loadFailed.value = false
try {
const [rolesData, permsData, sitesData, userRbac] = await Promise.all([
// `toast: true` : en cas d'echec, l'utilisateur voit un toast
// d'erreur. Sans ce feedback, le drawer s'afficherait vide et la
// sauvegarde ecraserait silencieusement l'etat RBAC du user.
api.get<{ member: Role[] }>('/roles', {}, { toast: true }),
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: true }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: true }),
api.get<UserRbacDetail>(`/users/${userId}/rbac`, {}, { toast: true }),
])
allRoles.value = rolesData.member
allPermissions.value = permsData.member
allSites.value = sitesData.member
form.value.isAdmin = userRbac.isAdmin
selectedRoleIds.value = new Set((userRbac.roles ?? []).map(iriToId))
selectedDirectPermissionIds.value = new Set((userRbac.directPermissions ?? []).map(iriToId))
selectedSiteIds.value = new Set((userRbac.sites ?? []).map(iriToId))
form.value.isAdmin = userRbac.isAdmin
selectedRoleIds.value = new Set((userRbac.roles ?? []).map(iriToId))
selectedDirectPermissionIds.value = new Set((userRbac.directPermissions ?? []).map(iriToId))
selectedSiteIds.value = new Set((userRbac.sites ?? []).map(iriToId))
} catch {
loadFailed.value = true
allRoles.value = []
allPermissions.value = []
allSites.value = []
resetForm()
}
}
function resetForm() {