Files
Starseed/frontend/modules/core/components/UserRbacDrawer.vue
T
tristan df8e44fcfa
Auto Tag Develop / tag (push) Successful in 8s
[ERP-70] feat(front) : adapter l'UI a @malio/layer-ui 1.7.3 (#33)
## Summary

Mise a jour de la lib `@malio/layer-ui` de `^1.7.2` vers `^1.7.3` et adaptation des ecrans pour deux changements visuels apportes par la lib :

- Le slot message (`error || success || hint`) sous les composants Malio est desormais **toujours rendu** dans le DOM (~16px en bas), pour eviter le saut de mise en page quand un champ passe en erreur.
- Nouvelle classe utilitaire `w-m-btn-action` pour standardiser la largeur des boutons d'action (remplacement du fix `w-[150px]`).

## Details

- **Bump dependance** : `frontend/package.json` + `frontend/package-lock.json` (`@malio/layer-ui` `^1.7.2` -> `^1.7.3`)
- **Boutons d'action** : 12 occurrences `button-class=\"w-[150px]\"` migrees vers `button-class=\"w-m-btn-action\"` dans `CategoryDrawer`, `RoleDrawer`, `SiteDrawer`, `UserRbacDrawer`, `audit-log`
- **Espacements formulaires** : rabotage des `gap-*` / `space-y-*` sur les conteneurs colonne (forms drawers, listes de checkbox, grille dates du drawer filtres audit-log, accordeon permissions, login) pour absorber le slot message desormais toujours present (16px)
- **Alignements verticaux** : compensation `pb-4` sur les voisins non-Malio dans les conteneurs `items-center` — puce couleur du `SiteDrawer` (`<div class=\"shrink-0 pb-4\">` autour du span) et labels `Du` / `Au` du drawer filtres `audit-log` (`<span class=\"pb-4\">`)
- **Layout** : reduction du padding lateral xl: dans `default.vue` (`xl:px-[170px]` -> `xl:px-[44px]`)

## Test plan

- [x] `make nuxt-test` (103/103 OK localement)
- [x] `make test` (322/322 OK localement)
- [x] Validation visuelle drawer Categories (Create / Edit / Delete)
- [x] Validation visuelle drawer Roles + accordeon permissions
- [x] Validation visuelle drawer Sites (puce couleur centree avec le champ)
- [x] Validation visuelle drawer Users RBAC
- [x] Validation visuelle page Audit Log (table + drawer filtres : dates Du/Au alignees, checkboxes correctement espacees)
- [x] Validation visuelle page Login (espacements entre champs / bouton / version)

## Suite

Un fix upstream `@malio/layer-ui` sera necessaire pour corriger l'alignement du label `Lignes :` dans la pagination de `MalioDataTable` (slot vide du `MalioSelect` interne) — prompt prepare a coller dans une session sur le repo de la lib.

Reviewed-on: #33
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-01 09:55:12 +00:00

331 lines
13 KiB
Vue

<template>
<MalioDrawer
:model-value="modelValue"
drawer-class="w-full max-w-[450px]"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<h2 class="text-[24px] font-bold">
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
</h2>
</template>
<div class="flex flex-col space-y-4 py-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"
class="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800"
>
<Icon name="mdi:alert-outline" class="size-5 shrink-0" />
{{ t('admin.users.drawer.selfWarning') }}
</div>
<!-- Toggle Administrateur -->
<MalioCheckbox
id="admin-toggle"
:label="t('admin.users.drawer.adminToggle')"
:model-value="form.isAdmin"
label-class="font-semibold text-sm text-neutral-700"
@update:model-value="form.isAdmin = $event"
/>
<!-- Section Roles -->
<!-- !mt-0 : la MalioCheckbox au-dessus expose son slot message (16px),
qui couvre deja l'ecart attendu pas besoin du space-y-4 ici. -->
<div class="!mt-0">
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.rolesSection') }}
</h4>
<div class="flex flex-col">
<MalioCheckbox
v-for="role in allRoles"
:key="role.id"
:id="`role-${role.id}`"
:label="role.label"
:model-value="selectedRoleIds.has(role.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => toggleRole(role.id, val)"
/>
</div>
</div>
<!-- Section Permissions directes -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.directPermissionsSection') }}
</h4>
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
{{ t('admin.roles.permissions.noPermissions') }}
</div>
<PermissionAccordion
v-else
:groups-by-module="permissionsByModule"
:selected-ids="selectedDirectPermissionIds"
id-prefix="direct"
@toggle="handleTogglePermission"
@toggle-all="handleToggleAll"
/>
</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">
<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">
{{ t('admin.users.drawer.summarySection') }}
</h4>
<EffectivePermissions :permissions="effectivePermissions" />
</div>
</div>
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton
:label="t('common.cancel')"
variant="tertiary"
button-class="w-m-btn-action"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
button-class="w-m-btn-action"
:disabled="saving || loadFailed"
@click="handleSave"
/>
</template>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Permission, PermissionModule, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
const auth = useAuthStore()
const props = defineProps<{
modelValue: boolean
user: UserListItem | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
}>()
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>())
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)
// Extraire un ID depuis une IRI API Platform
function iriToId(iri: string): number {
return Number(iri.split('/').pop())
}
// Grouper les permissions par module (pour les checkboxes)
const permissionsByModule = computed<PermissionModule[]>(() => {
const groups = new Map<string, Permission[]>()
for (const perm of allPermissions.value) {
if (perm.orphan) continue
const list = groups.get(perm.module) || []
list.push(perm)
groups.set(perm.module, list)
}
return Array.from(groups.entries())
.map(([module, permissions]) => ({ module, permissions }))
.sort((a, b) => a.module.localeCompare(b.module))
})
// Calculer les permissions effectives avec leurs sources
const effectivePermissions = computed<EffectivePermission[]>(() => {
const permMap = new Map<number, Permission>()
for (const p of allPermissions.value) {
if (!p.orphan) permMap.set(p.id, p)
}
// Construire la map permissionId -> sources[]
const result = new Map<number, string[]>()
// Permissions heritees des roles
for (const roleId of selectedRoleIds.value) {
const role = allRoles.value.find(r => r.id === roleId)
if (!role) continue
for (const p of role.permissions) {
const pid = typeof p === 'string' ? iriToId(p) : p.id
const sources = result.get(pid) || []
sources.push(t('admin.users.drawer.sourceRole', { role: role.label }))
result.set(pid, sources)
}
}
// Permissions directes
for (const pid of selectedDirectPermissionIds.value) {
const sources = result.get(pid) || []
sources.push(t('admin.users.drawer.sourceDirect'))
result.set(pid, sources)
}
// Construire la liste finale
return Array.from(result.entries())
.map(([pid, sources]) => {
const perm = permMap.get(pid)
if (!perm) return null
return { code: perm.code, label: perm.label, module: perm.module, sources }
})
.filter((p): p is EffectivePermission => p !== null)
.sort((a, b) => a.code.localeCompare(b.code))
})
// Charger les referentiels (roles, permissions, sites) + le detail RBAC du user
// en parallele pour minimiser le TTFB a l'ouverture du drawer.
// 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) {
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))
} catch {
loadFailed.value = true
allRoles.value = []
allPermissions.value = []
allSites.value = []
resetForm()
}
}
function resetForm() {
form.value.isAdmin = false
selectedRoleIds.value = new Set()
selectedDirectPermissionIds.value = new Set()
selectedSiteIds.value = new Set()
}
// Recharger a l'ouverture OU quand le user change pendant que le drawer est ouvert.
// Le watch combine evite un double fetch si les deux changent dans le meme tick.
watch([() => props.modelValue, () => props.user?.id], ([open, userId]) => {
if (open && userId) {
loadData(userId)
} else if (!open) {
resetForm()
}
}, { immediate: true })
function toggleRole(id: number, selected: boolean) {
const ids = new Set(selectedRoleIds.value)
if (selected) ids.add(id)
else ids.delete(id)
selectedRoleIds.value = ids
}
function handleTogglePermission(id: number, selected: boolean) {
const ids = new Set(selectedDirectPermissionIds.value)
if (selected) ids.add(id)
else ids.delete(id)
selectedDirectPermissionIds.value = ids
}
function handleToggleAll(module: string, selected: boolean) {
const ids = new Set(selectedDirectPermissionIds.value)
const group = permissionsByModule.value.find(g => g.module === module)
if (!group) return
for (const perm of group.permissions) {
if (selected) ids.add(perm.id)
else ids.delete(perm.id)
}
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
try {
await api.patch(`/users/${props.user.id}/rbac`, {
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'),
})
// Rafraichir les donnees du user courant si auto-edition
if (isSelfEdit.value) {
await auth.refreshUser()
}
emit('saved')
emit('update:modelValue', false)
} finally {
saving.value = false
}
}
</script>