Merge branch 'develop' into feature/ERP-41-mise-a-jour-malio-ui

# Conflicts:
#	config/reference.php
This commit is contained in:
2026-05-13 10:36:28 +02:00
92 changed files with 8543 additions and 2309 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>
@@ -112,7 +122,7 @@
</template>
<script setup lang="ts">
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
interface PermissionModule {
@@ -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>())
@@ -206,39 +220,56 @@ const effectivePermissions = computed<EffectivePermission[]>(() => {
.sort((a, b) => a.code.localeCompare(b.code))
})
// Charger roles, permissions et sites en parallele pour minimiser le TTFB
// a l'ouverture du drawer.
async function loadData() {
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
// 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()
}
}
// Remplir le formulaire quand le user change
watch(() => props.user, (user) => {
if (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()
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 })
// Charger les donnees quand le drawer s'ouvre
watch(() => props.modelValue, (open) => {
if (open) loadData()
})
function toggleRole(id: number, selected: boolean) {
const ids = new Set(selectedRoleIds.value)
if (selected) ids.add(id)

View File

@@ -0,0 +1,422 @@
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.auditLog.title') }}
</h1>
</div>
<!-- Filtres -->
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
leur `label` flottant interne pour ne pas mixer deux patterns de label. -->
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
exposera un datetime picker. Cf. exception documentee dans
CLAUDE.md (section "Composants formulaires"). -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_from') }}
</label>
<input
v-model="filters.performedAtAfter"
type="datetime-local"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<!-- TODO(malio-ui): idem ci-dessus. -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_to') }}
</label>
<input
v-model="filters.performedAtBefore"
type="datetime-local"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.entity_type') }}
</label>
<div class="[&>div>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedEntityTypes"
:options="entityTypeOptions"
:display-select-all="true"
:display-tag="true"
min-width="w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.user') }}
</label>
<MalioInputText
v-model="performedByInput"
icon-name="mdi:account-search"
input-class="text-sm"
group-class="h-10"
/>
</div>
<!-- TODO(malio-ui): remplacer par MalioSelect quand la lib
supportera de maniere fiable des options a valeur string
(cf. note Lesstime CLAUDE.md). Exception documentee dans
CLAUDE.md (section "Composants formulaires"). -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.action') }}
</label>
<select
v-model="actionValue"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
<option value="">{{ t('audit.filters.all_actions') }}</option>
<option v-for="opt in actionOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</div>
<div class="mt-3 flex justify-end">
<MalioButton
variant="tertiary"
:label="t('audit.filters.reset')"
button-class="text-xs"
@click="resetFilters"
/>
</div>
</section>
<!-- Tableau -->
<MalioDataTable
class="mt-4"
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="filters.page ?? 1"
:per-page="filters.itemsPerPage ?? 10"
:per-page-options="[10, 25, 50]"
:empty-message="isFiltered ? t('audit.no_results') : t('audit.empty')"
@update:page="onPageChange"
@update:per-page="onPerPageChange"
@row-click="onRowClick"
>
<template #cell-action="{ item }">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="actionBadgeClass(item.action as string)"
>
{{ t(`audit.action.${item.action}`) }}
</span>
</template>
<template #cell-entityType="{ item }">
<span
class="text-xs"
:title="item.entityType as string"
>{{ formatEntityType(item.entityType as string) }}</span>
</template>
<template #cell-entityId="{ item }">
<span class="font-mono text-xs">{{ item.entityId }}</span>
</template>
<template #cell-summary="{ item }">
<span class="text-xs text-gray-600">{{ item.summary }}</span>
</template>
</MalioDataTable>
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
<MalioDrawer
v-model="drawerOpen"
:title="drawerTitle"
drawer-class="max-w-2xl"
>
<div v-if="selectedEntry">
<AuditLogDetail :entry="selectedEntry" />
<div class="mt-4 border-t border-gray-200 pt-3">
<h3
class="text-sm font-medium text-gray-700 mb-2"
:title="selectedEntry.entityType"
>
{{ formatEntityType(selectedEntry.entityType) }} #{{ selectedEntry.entityId }}
</h3>
<AuditTimeline
:entity-type="selectedEntry.entityType"
:entity-id="selectedEntry.entityId"
/>
</div>
</div>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
const { t, te } = useI18n()
const { can } = usePermissions()
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
// traduction n'existe, on retombe sur l'identifiant brut pour rester debug-friendly.
function formatEntityType(type: string): string {
const key = `audit.entity.${type.toLowerCase().replace(/\./g, '_')}`
return te(key) ? t(key) : type
}
// Protection cote UI : le middleware `modules.global.ts` filtre deja les
// routes desactivees, mais si quelqu'un atterit ici sans la permission on
// renvoie une 403 plutot que de flasher un ecran vide.
if (!can('core.audit_log.view')) {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
}
useHead({ title: t('admin.auditLog.title') })
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
// CLAUDE.md "Tableau : pas de persistance URL").
const filters = reactive<AuditLogFilters>({
performedAtAfter: undefined,
performedAtBefore: undefined,
entityType: undefined,
performedBy: undefined,
action: undefined,
page: 1,
itemsPerPage: 10,
})
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
const selectedEntityTypes = ref<(string | number)[]>([])
const entityTypes = ref<string[]>([])
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
const entityTypeOptions = computed(() =>
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
)
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
// pas binder directement un `string | undefined` reactive.
const performedByInput = ref<string>('')
// Action : MalioSelect ne gere pas fiablement des options a valeur string (cf.
// note Lesstime CLAUDE.md). On utilise un `<select>` natif stylise comme les
// inputs dates pour garder un look coherent. '' = "toutes les actions".
const actionValue = ref<string>('')
const actionOptions = [
{ value: 'create', label: t('audit.action.create') },
{ value: 'update', label: t('audit.action.update') },
{ value: 'delete', label: t('audit.action.delete') },
]
const entries = ref<AuditLogEntry[]>([])
const totalItems = ref(0)
const loading = ref(false)
const drawerOpen = ref(false)
const selectedEntry = ref<AuditLogEntry | null>(null)
const columns = [
{ key: 'performedAt', label: t('admin.auditLog.table.performedAt') },
{ key: 'performedBy', label: t('admin.auditLog.table.performedBy') },
{ key: 'entityType', label: t('admin.auditLog.table.entityType') },
{ key: 'entityId', label: t('admin.auditLog.table.entityId') },
{ key: 'action', label: t('admin.auditLog.table.action') },
{ key: 'summary', label: t('admin.auditLog.table.summary') },
]
// Transforme chaque AuditLogEntry en ligne compatible MalioDataTable.
// On conserve `id` pour retrouver l'entry complete sur row-click.
const rows = computed(() =>
entries.value.map(entry => ({
id: entry.id,
performedAt: formatDate(entry.performedAt),
performedBy: entry.performedBy,
entityType: entry.entityType,
entityId: entry.entityId,
action: entry.action,
summary: summarize(entry),
})),
)
const drawerTitle = computed(() =>
selectedEntry.value
? `${formatEntityType(selectedEntry.value.entityType)} #${selectedEntry.value.entityId}`
: t('audit.detail_title'),
)
const isFiltered = computed(() =>
Boolean(filters.performedAtAfter || filters.performedAtBefore
|| (Array.isArray(filters.entityType) ? filters.entityType.length : filters.entityType)
|| filters.performedBy || filters.action),
)
// Anti-race : chaque fetch incremente un compteur ; seul le dernier en date
// ecrit les resultats dans `entries`/`totalItems`. Evite qu'une reponse tardive
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
let requestToken = 0
// Pendant un reset, on suspend temporairement les watchers pour ne pas
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
// et les fetchs partent quand meme. Un seul loadEntries() est appele
// explicitement apres la liberation.
let watchersSuspended = false
async function resetFilters(): Promise<void> {
watchersSuspended = true
filters.performedAtAfter = undefined
filters.performedAtBefore = undefined
filters.entityType = undefined
filters.performedBy = undefined
filters.action = undefined
filters.page = 1
selectedEntityTypes.value = []
performedByInput.value = ''
actionValue.value = ''
// Les watchers mute de Vue 3 se planifient en microtask : on attend
// leur execution avec le flag `true`, puis on libere.
await nextTick()
watchersSuspended = false
loadEntries()
}
async function loadEntries(): Promise<void> {
const token = ++requestToken
loading.value = true
try {
const data = await fetchLogsCached({
...filters,
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
})
// Reponse obsolete (un fetch plus recent a ete lance entre-temps) :
// on ignore le resultat pour ne pas overwrite l'etat courant.
if (token !== requestToken) return
entries.value = data.member ?? []
totalItems.value = data.totalItems ?? 0
} catch {
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas
// laisser l'utilisateur croire que les donnees affichees sont a jour.
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog.
if (token === requestToken) {
entries.value = []
totalItems.value = 0
}
} finally {
if (token === requestToken) {
loading.value = false
}
}
}
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
const debouncedReload = debounce(() => loadEntries(), 300)
function toIso(localDateTime: string): string {
// datetime-local n'a pas de timezone : on assume heure locale et on
// laisse le navigateur generer l'ISO via Date().
return new Date(localDateTime).toISOString()
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('fr-FR', {
dateStyle: 'short',
timeStyle: 'short',
})
}
function actionBadgeClass(action: string): string {
switch (action) {
case 'create': return 'bg-green-100 text-green-800'
case 'update': return 'bg-yellow-100 text-yellow-800'
case 'delete': return 'bg-red-100 text-red-800'
default: return 'bg-gray-100 text-gray-800'
}
}
function summarize(entry: AuditLogEntry): string {
const keys = Object.keys(entry.changes)
if (keys.length === 0) return '—'
if (keys.length <= 3) return keys.join(', ')
return `${keys.slice(0, 3).join(', ')}… (+${keys.length - 3})`
}
function onRowClick(item: Record<string, unknown>): void {
const entry = entries.value.find(e => e.id === item.id)
if (entry) {
selectedEntry.value = entry
drawerOpen.value = true
}
}
function onPageChange(value: number): void {
filters.page = value
loadEntries()
}
function onPerPageChange(value: number): void {
filters.itemsPerPage = value
filters.page = 1
loadEntries()
}
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
watch(selectedEntityTypes, values => {
if (watchersSuspended) return
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
filters.page = 1
loadEntries()
})
// Sync select action natif -> filters.action.
watch(actionValue, value => {
if (watchersSuspended) return
filters.action = value === '' ? undefined : value
filters.page = 1
loadEntries()
})
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
// refetch par caractere. Le reset passe par debouncedReload egalement pour
// coalescer si plusieurs watchers tirent en meme temps.
watch(performedByInput, value => {
if (watchersSuspended) return
filters.performedBy = value === '' ? undefined : value
filters.page = 1
debouncedReload()
})
// Synchronisation reactive : tout changement de dates declenche un fetch +
// reset de la pagination a la page 1.
watch(
() => [filters.performedAtAfter, filters.performedAtBefore],
() => {
if (watchersSuspended) return
filters.page = 1
loadEntries()
},
)
onMounted(async () => {
// Charge les entity types en parallele de la liste principale : un
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
try {
entityTypes.value = await fetchEntityTypes()
} catch {
entityTypes.value = []
}
await loadEntries()
})
</script>

View File

@@ -114,6 +114,10 @@ async function loadRoles() {
{ toast: false },
)
roles.value = data.member
} catch {
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
// requete reussie avant une perte reseau ou 403).
roles.value = []
} finally {
loading.value = false
}

View File

@@ -38,7 +38,6 @@
<script setup lang="ts">
import type { UserListItem } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
@@ -49,24 +48,21 @@ useHead({ title: t('admin.users.title') })
const canManage = computed(() => can('core.users.manage'))
const users = ref<UserListItem[]>([])
const sitesById = ref(new Map<number, Site>())
const loading = ref(false)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
// La colonne "Sites" n'est plus affichee dans la liste : le detail des sites
// rattaches est consulte/edite via le drawer (GET /users/{id}/rbac). Garder
// un payload leger sur /api/users facilite la pagination et evite de fuiter
// l'info cross-site aux users partageant juste un site avec l'appelant.
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') },
]
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
function iriToId(iri: string): number {
return Number(iri.split('/').pop())
}
const userItems = computed(() =>
users.value.map(user => ({
id: user.id,
@@ -74,27 +70,19 @@ const userItems = computed(() =>
admin: user.isAdmin,
roles: user.roles.length,
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.
sites: (user.sites ?? [])
.map(iri => sitesById.value.get(iriToId(iri))?.name)
.filter((name): name is string => Boolean(name))
.join(', '),
})),
)
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 }),
])
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
users.value = usersData.member
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
} catch {
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
// requete reussie avant une perte reseau ou 403). Pas de toast par
// design ici : on laisse la liste vide parler d'elle-meme.
users.value = []
} finally {
loading.value = false
}

View File

@@ -11,6 +11,7 @@ const auth = useAuthStore()
const { resetSidebar } = useSidebar()
const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite()
const { resetAuditLog } = useAuditLog()
onMounted(async () => {
try {
@@ -18,13 +19,14 @@ onMounted(async () => {
} finally {
// Les resets sont garantis meme si auth.logout() rejette : eviter
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// l'ancien. Les trois fonctions reset sont synchrones et ne
// l'ancien. Toutes les 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()
resetAuditLog()
await navigateTo('/login')
}
})

View File

@@ -118,6 +118,10 @@ async function loadSites() {
{ toast: false },
)
sites.value = data.member
} catch {
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
// requete reussie avant une perte reseau ou 403).
sites.value = []
} finally {
loading.value = false
}