Module sites (#8)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: MALIO-DEV/Coltura#8
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #8.
This commit is contained in:
2026-04-20 15:31:58 +00:00
committed by Autin
parent 6b4868b261
commit 6cf5ef4cfc
77 changed files with 7739 additions and 80 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'),
})

View File

@@ -38,6 +38,7 @@
<script setup lang="ts">
import type { UserListItem } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
@@ -48,6 +49,7 @@ 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)
@@ -57,8 +59,14 @@ const columns = [
{ 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,
@@ -66,18 +74,27 @@ 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 {
const data = await api.get<{ member: UserListItem[] }>(
'/users',
{},
{ toast: false },
)
users.value = data.member
// 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
}

View File

@@ -9,10 +9,23 @@ definePageMeta({ layout: 'auth' })
const auth = useAuthStore()
const { resetSidebar } = useSidebar()
const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite()
onMounted(async () => {
await auth.logout()
resetSidebar()
await navigateTo('/login')
try {
await auth.logout()
} 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
// 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')
}
})
</script>

View File

@@ -0,0 +1,76 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="cancel"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-neutral-900">
{{ t('admin.sites.delete.title') }}
</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ t('admin.sites.delete.message', { name: siteName }) }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
:label="t('common.cancel')"
variant="secondary"
@click="cancel"
/>
<MalioButton
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="loading"
@click="confirm"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const { t } = useI18n()
defineProps<{
modelValue: boolean
siteName: string
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
}>()
function cancel() {
emit('update:modelValue', false)
}
function confirm() {
emit('confirm')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancel()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<MalioDrawer
:model-value="modelValue"
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
drawer-class="w-full max-w-lg"
@update:model-value="emit('update:modelValue', $event)"
>
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.name"
:label="t('admin.sites.form.name')"
input-class="w-full"
required
/>
<MalioInputText
v-model="form.street"
:label="t('admin.sites.form.street')"
input-class="w-full"
required
/>
<MalioInputText
v-model="form.complement"
:label="t('admin.sites.form.complement')"
:placeholder="t('admin.sites.form.complementPlaceholder')"
input-class="w-full"
/>
<!-- Code postal FR : masque "#####" (5 chiffres stricts) +
maxLength en double securite. La regex backend validera la
forme finale, le masque empeche juste la saisie de
caracteres non numeriques. -->
<MalioInputText
v-model="form.postalCode"
:label="t('admin.sites.form.postalCode')"
input-class="w-full"
mask="#####"
max-length="5"
required
/>
<MalioInputText
v-model="form.city"
:label="t('admin.sites.form.city')"
input-class="w-full"
required
/>
<!-- Champ couleur avec preview puce -->
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">
{{ t('admin.sites.form.color') }}
</label>
<div class="flex items-center gap-3">
<MalioInputText
v-model="form.color"
placeholder="#RRGGBB"
input-class="w-full font-mono"
required
/>
<span
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
:class="{ 'border-dashed': !isValidHex }"
/>
</div>
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
{{ t('admin.sites.form.colorInvalid') }}
</p>
</div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving || !isValidHex"
@click="handleSave"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Site } from '~/shared/types/sites'
import { isValidSiteColor } from '~/shared/utils/color'
const { t } = useI18n()
const api = useApi()
const props = defineProps<{
modelValue: boolean
site: Site | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
delete: []
}>()
const saving = ref(false)
const form = ref({
name: '',
street: '',
complement: '',
postalCode: '',
city: '',
color: '#000000',
})
const isEditMode = computed(() => props.site !== null)
// Validation locale du format hex #RRGGBB avant envoi backend.
const isValidHex = computed(() => isValidSiteColor(form.value.color))
// Remplir le formulaire quand le site change
watch(() => props.site, (site) => {
if (site) {
form.value.name = site.name
form.value.street = site.street
form.value.complement = site.complement ?? ''
form.value.postalCode = site.postalCode
form.value.city = site.city
form.value.color = site.color
} else {
form.value.name = ''
form.value.street = ''
form.value.complement = ''
form.value.postalCode = ''
form.value.city = ''
form.value.color = '#056CF2'
}
}, { immediate: true })
async function handleSave() {
if (!isValidHex.value) return
saving.value = true
try {
// Le champ complement est optionnel cote DB : on envoie null si vide
// pour que le backend stocke NULL plutot qu'une chaine vide.
const trimmedComplement = form.value.complement.trim()
const payload = {
name: form.value.name,
street: form.value.street,
complement: trimmedComplement === '' ? null : trimmedComplement,
postalCode: form.value.postalCode,
city: form.value.city,
color: form.value.color,
}
if (isEditMode.value && props.site) {
await api.patch(`/sites/${props.site.id}`, payload, {
toastSuccessMessage: t('admin.sites.toast.updated'),
})
} else {
await api.post('/sites', payload, {
toastSuccessMessage: t('admin.sites.toast.created'),
})
}
emit('saved')
emit('update:modelValue', false)
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,92 @@
<template>
<MalioSiteSelector
:sites="mappedSites"
:model-value="currentSite ? String(currentSite.id) : undefined"
:group-class="groupClass"
:tile-class="tileClass"
:label-class="labelClass"
:aria-label="t('sites.selector.ariaGroupLabel')"
@change="onChange"
/>
</template>
<script setup lang="ts">
const { t } = useI18n()
const { currentSite, availableSites, syncFromAuth, switchSite } = useCurrentSite()
const auth = useAuthStore()
// Hydratation initiale + watcher : garde le state aligne sur auth.user
// meme si un autre composant modifie auth.user.currentSite (ex: switch
// depuis un autre onglet via /api/me/current-site, ou refresh du token).
// Le rollback de switchSite restaure AUSSI auth.user.currentSite (voir
// useCurrentSite::switchSite) pour eviter tout cycle watchEffect -> sync
// qui ecraserait l'etat local apres une erreur PATCH.
watchEffect(() => {
void auth.user?.currentSite
void auth.user?.sites
syncFromAuth()
})
// Conversion id number -> string : l'API de MalioSiteSelector (v1.4.0)
// travaille en string alors que notre type metier Site utilise un int
// (ID Doctrine). On reconvertit dans onChange.
const mappedSites = computed(() =>
availableSites.value.map(site => ({
id: String(site.id),
name: site.name,
color: site.color,
})),
)
// Note de rendu : MalioSiteSelector v1.4.0 utilise UNE SEULE `activeColor`
// (couleur du site courant) comme fond pour TOUS les tiles. Les inactifs
// sont differencies uniquement par `opacity: 0.4`. Le texte est TOUJOURS
// blanc (conforme maquette Figma) — charge aux admins de choisir des
// couleurs de site suffisamment foncees pour garantir la lisibilite.
// On surcharge `labelClass` uniquement pour imposer la taille 24px
// (Figma), le reste des attributs tex (blanc, bold, uppercase, tracking)
// vient du default Malio via twMerge.
// Classes Tailwind passees a MalioSiteSelector via twMerge :
// - groupClass : hauteur fixe 72px (spec Figma) + scroll horizontal si
// debordement de 4+ sites sur petits ecrans.
// - tileClass : largeur minimale pour lisibilite + focus ring WCAG.
// - labelClass : taille de texte 24px imposee par la maquette Figma.
// Tailwind `text-2xl` = 1.5rem = 24px. Merge avec le default Malio
// (`text-white font-bold uppercase tracking-wide`).
const groupClass = 'h-[72px] overflow-x-auto'
const tileClass = 'min-w-[200px] flex items-center justify-center focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2'
const labelClass = 'text-2xl'
async function onChange(site: { id: string; name: string; color: string }): Promise<void> {
const target = availableSites.value.find(s => String(s.id) === site.id)
if (!target) {
// Divergence entre mappedSites et availableSites (peut arriver si
// un refresh concurrent a vide la collection). On ignore mais on
// trace en dev pour faciliter le debug.
if (import.meta.dev) {
// Utilise console.error (pas warn) car la convention projet
// eslint n'autorise que error (no-console avec allow: ['error']).
console.error(`[SiteSelector] Site inconnu emis par MalioSiteSelector : id=${site.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)
} catch {
// L'erreur est deja toastee par useApi ; le composable a rollback
// le state local ET le store auth. Rien a faire ici au-dela de
// silencer pour eviter une unhandledRejection dans la console.
}
}
</script>

View File

@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { computed, defineComponent, h, ref, watchEffect } from 'vue'
import type { Site } from '~/shared/types/sites'
import { useCurrentSite } from '~/modules/sites/composables/useCurrentSite'
import SiteSelector from '../SiteSelector.vue'
const mockPatch = vi.hoisted(() => vi.fn())
const mockAuthUser = vi.hoisted(() => ({
value: null as { sites: Site[]; currentSite: Site | null } | null,
}))
// Stubs des auto-imports Nuxt. SiteSelector.vue utilise useCurrentSite,
// useAuthStore, useI18n, watchEffect, computed sans import explicite
// (pattern Nuxt). En Vitest on les expose comme globals.
vi.stubGlobal('useCurrentSite', useCurrentSite)
vi.stubGlobal('useApi', () => ({ patch: mockPatch }))
vi.stubGlobal('useAuthStore', () => ({
get user() {
return mockAuthUser.value
},
setCurrentSite(site: Site | null) {
if (mockAuthUser.value) {
mockAuthUser.value.currentSite = site
}
},
}))
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
// monter la vraie lib Malio (qui aurait besoin de tout Tailwind + twMerge).
const MalioSiteSelectorStub = defineComponent({
name: 'MalioSiteSelector',
props: {
sites: { type: Array, required: true },
modelValue: { type: String, default: undefined },
groupClass: { type: String, default: '' },
tileClass: { type: String, default: '' },
labelClass: { type: String, default: '' },
},
emits: ['update:modelValue', 'change'],
setup(props, { emit }) {
return () => h('div', {
'data-testid': 'malio-site-selector',
'data-sites-count': String((props.sites as unknown[]).length),
'data-active-id': String(props.modelValue ?? ''),
'data-label-class': props.labelClass,
}, [
...(props.sites as Array<{ id: string; name: string; color: string }>).map(site =>
h('button', {
'data-testid': `tile-${site.id}`,
// Emet les deux events comme le vrai MalioSiteSelector
// (update:modelValue + change). Le wrapper n'ecoute que
// change aujourd'hui, mais tracker les deux grave la
// signature et prepare un eventuel v-model futur.
onClick: () => {
emit('update:modelValue', site.id)
emit('change', site)
},
}, site.name),
),
])
},
})
const SITE_A: Site = {
id: 1,
name: 'Chatellerault',
street: '14 All.',
complement: null,
postalCode: '86100',
city: 'Châtellerault',
color: '#056CF2',
fullAddress: '14 All.\n86100 Châtellerault',
}
const SITE_B: Site = {
id: 2,
name: 'Saint-Jean',
street: 'Z i',
complement: null,
postalCode: '17400',
city: 'Fontenet',
color: '#F3CB00',
fullAddress: 'Z i\n17400 Fontenet',
}
function mountSelector() {
return mount(SiteSelector, {
global: {
stubs: { MalioSiteSelector: MalioSiteSelectorStub },
},
})
}
describe('SiteSelector', () => {
beforeEach(() => {
mockPatch.mockReset()
mockAuthUser.value = {
sites: [SITE_A, SITE_B],
currentSite: SITE_A,
}
})
it('rend un tile par site autorise', () => {
const wrapper = mountSelector()
const stub = wrapper.find('[data-testid="malio-site-selector"]')
expect(stub.attributes('data-sites-count')).toBe('2')
})
it('marque le site courant via modelValue (string)', () => {
const wrapper = mountSelector()
const stub = wrapper.find('[data-testid="malio-site-selector"]')
// Chatellerault id=1 => '1'
expect(stub.attributes('data-active-id')).toBe('1')
})
it('passe labelClass="text-2xl" pour forcer 24px conforme Figma', () => {
// Decision design : texte blanc par defaut Malio mais taille 24px
// imposee par la maquette. Le reste des attributs text (white, bold,
// uppercase, tracking-wide) provient du default Malio via twMerge.
const wrapper = mountSelector()
const stub = wrapper.find('[data-testid="malio-site-selector"]')
expect(stub.attributes('data-label-class')).toBe('text-2xl')
})
it('clic sur un tile inactif declenche switchSite via PATCH /me/current-site', async () => {
mockPatch.mockResolvedValueOnce({})
const wrapper = mountSelector()
await wrapper.find('[data-testid="tile-2"]').trigger('click')
await flushPromises()
expect(mockPatch).toHaveBeenCalledWith(
'/me/current-site',
{ site: '/api/sites/2' },
expect.anything(),
)
})
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).toHaveBeenCalledWith(
'/me/current-site',
{ site: '/api/sites/1' },
expect.anything(),
)
})
it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => {
// Scenario : admin clique sur Saint-Jean alors que Chatellerault est
// actif, mais le serveur rejette (ex : 500). Apres rollback dans
// useCurrentSite, le composant doit re-afficher Chatellerault actif.
mockPatch.mockRejectedValueOnce(new Error('server down'))
const wrapper = mountSelector()
// Avant : Chatellerault (id=1) actif.
expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id'))
.toBe('1')
await wrapper.find('[data-testid="tile-2"]').trigger('click')
await flushPromises()
// Apres rollback : Chatellerault (id=1) de nouveau actif.
expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id'))
.toBe('1')
// Le store auth ne doit PAS avoir ete laisse avec SITE_B.
expect(mockAuthUser.value?.currentSite).toEqual(SITE_A)
})
})

View File

@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Site } from '~/shared/types/sites'
import { useCurrentSite } from '../useCurrentSite'
const mockPatch = vi.hoisted(() => vi.fn())
const mockAuthUser = vi.hoisted(() => ({
value: null as { sites: Site[]; currentSite: Site | null } | null,
}))
// Stub des auto-imports Nuxt consommes par le composable.
vi.stubGlobal('useApi', () => ({ patch: mockPatch }))
vi.stubGlobal('useAuthStore', () => ({
get user() {
return mockAuthUser.value
},
// Mime l'action Pinia ajoutee au ticket 3 review (S6) : mute
// user.currentSite si user present, no-op sinon.
setCurrentSite(site: Site | null) {
if (mockAuthUser.value) {
mockAuthUser.value.currentSite = site
}
},
}))
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,
name: 'Chatellerault',
street: '14 All. d\'Argenson',
complement: null,
postalCode: '86100',
city: 'Châtellerault',
color: '#056CF2',
fullAddress: '14 All. d\'Argenson\n86100 Châtellerault',
}
const SITE_B: Site = {
id: 2,
name: 'Saint-Jean',
street: 'Z i',
complement: null,
postalCode: '17400',
city: 'Fontenet',
color: '#F3CB00',
fullAddress: 'Z i\n17400 Fontenet',
}
describe('useCurrentSite', () => {
beforeEach(() => {
mockPatch.mockReset()
mockAuthUser.value = {
sites: [SITE_A, SITE_B],
currentSite: SITE_A,
}
const { resetCurrentSite } = useCurrentSite()
resetCurrentSite()
})
it('syncFromAuth hydrate le state depuis le store auth', () => {
const { syncFromAuth, currentSite, availableSites } = useCurrentSite()
syncFromAuth()
expect(currentSite.value).toEqual(SITE_A)
expect(availableSites.value).toEqual([SITE_A, SITE_B])
})
it('syncFromAuth gere le cas user null (deconnecte)', () => {
mockAuthUser.value = null
const { syncFromAuth, currentSite, availableSites } = useCurrentSite()
syncFromAuth()
expect(currentSite.value).toBeNull()
expect(availableSites.value).toEqual([])
})
it('switchSite met a jour currentSite localement AVANT la requete (optimistic)', async () => {
mockPatch.mockImplementation(async () => {
// Au moment du resolve, currentSite est deja basculé.
const state = useCurrentSite()
expect(state.currentSite.value).toEqual(SITE_B)
return {}
})
const { syncFromAuth, switchSite, currentSite } = useCurrentSite()
syncFromAuth()
await switchSite(SITE_B)
expect(currentSite.value).toEqual(SITE_B)
expect(mockPatch).toHaveBeenCalledWith(
'/me/current-site',
{ site: '/api/sites/2' },
expect.objectContaining({ toastSuccessMessage: expect.any(String) }),
)
})
it('switchSite propage le nouveau currentSite au store auth en cas de succes', async () => {
mockPatch.mockResolvedValueOnce({})
const { syncFromAuth, switchSite } = useCurrentSite()
syncFromAuth()
await switchSite(SITE_B)
expect(mockAuthUser.value?.currentSite).toEqual(SITE_B)
})
it('switchSite rollback le currentSite local si la requete echoue', async () => {
mockPatch.mockRejectedValueOnce(new Error('network'))
const { syncFromAuth, switchSite, currentSite } = useCurrentSite()
syncFromAuth()
await expect(switchSite(SITE_B)).rejects.toThrow('network')
expect(currentSite.value).toEqual(SITE_A)
})
it('switchSite ne propage pas au store auth en cas d\'echec', async () => {
mockPatch.mockRejectedValueOnce(new Error('network'))
const { syncFromAuth, switchSite } = useCurrentSite()
syncFromAuth()
await expect(switchSite(SITE_B)).rejects.toThrow()
expect(mockAuthUser.value?.currentSite).toEqual(SITE_A)
})
it('switching est vrai pendant la requete et faux apres', async () => {
let resolveRequest: (value: unknown) => void = () => {}
mockPatch.mockImplementation(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const { syncFromAuth, switchSite, switching } = useCurrentSite()
syncFromAuth()
const pending = switchSite(SITE_B)
expect(switching.value).toBe(true)
resolveRequest({})
await pending
expect(switching.value).toBe(false)
})
it('double switchSite concurrent : le second appel est un no-op silencieux', async () => {
let resolveRequest: (value: unknown) => void = () => {}
mockPatch.mockImplementation(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const { syncFromAuth, switchSite } = useCurrentSite()
syncFromAuth()
const first = switchSite(SITE_B)
await switchSite(SITE_A) // doit etre no-op (switching=true)
// Le second appel ne declenche pas de PATCH additionnel.
expect(mockPatch).toHaveBeenCalledTimes(1)
resolveRequest({})
await first
})
it('resetCurrentSite vide tout l\'etat singleton', () => {
const { syncFromAuth, resetCurrentSite, currentSite, availableSites, switching } = useCurrentSite()
syncFromAuth()
expect(currentSite.value).not.toBeNull()
resetCurrentSite()
expect(currentSite.value).toBeNull()
expect(availableSites.value).toEqual([])
expect(switching.value).toBe(false)
})
it('capture useI18n/useApi/useAuthStore UNE FOIS au setup (garde anti-regression bug runtime)', async () => {
// Historique : une premiere version du composable appelait useI18n()
// dans `switchSite` plutot qu'au top du setup. Consequence en runtime :
// l'appel depuis un event handler (click) hors contexte setup levait
// "Must be called at the top of a setup function". Ce test grave le
// contrat : useCurrentSite() DOIT capturer les 3 services a
// l'initialisation, pas paresseusement.
//
// Verification : on remplace useI18n par un mock qui throw au 2e appel.
// Si switchSite invoque useI18n() lui-meme, ce test cassera.
let i18nCallCount = 0
vi.stubGlobal('useI18n', () => {
i18nCallCount++
if (i18nCallCount > 1) {
throw new Error('useI18n called more than once — regression bug runtime')
}
return { t: (key: string) => key }
})
mockPatch.mockResolvedValueOnce({})
const { syncFromAuth, switchSite } = useCurrentSite()
syncFromAuth()
// Si switchSite appelait useI18n() en interne, ce call incrementerait
// i18nCallCount a 2 et throw. La garde du test passe uniquement si
// la capture a bien eu lieu au setup (i18nCallCount reste a 1).
await switchSite(SITE_B)
expect(i18nCallCount).toBe(1)
// Restaure le stub par defaut pour les tests suivants.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
})
})

View File

@@ -0,0 +1,130 @@
/**
* Composable de gestion du site courant (ticket 3 module Sites).
*
* Pattern aligne sur `useSidebar` : state singleton au niveau module,
* hydrate depuis `useAuthStore().user`, mute de maniere optimistic avec
* rollback si la requete PATCH `/api/me/current-site` echoue.
*
* Garantie d'unicite : le flag `switching` bloque les double-clicks
* concurrents. Le reset explicite est appele au logout
* (voir `modules/core/pages/logout.vue`).
*
* Auto-select : aucun. Le backend (`UserRbacProcessor::ensureCurrentSiteConsistency`)
* garantit deja l'invariant "user avec sites non vide => currentSite non null"
* apres tout PATCH /rbac. Le front consomme l'etat renvoye tel quel.
*
* Contrainte d'appel : `useCurrentSite()` doit etre invoque au top du
* `setup()` d'un composant (ou d'un autre composable appele au setup).
* Les dependances `useI18n`, `useApi` et `useAuthStore` sont resolues
* a l'initialisation et reutilisees par `switchSite` — ceci evite le
* "Must be called at the top of a setup function" qui se produirait
* si on les appelait paresseusement depuis une fonction async declenchee
* par un handler d'event (hors contexte setup).
*/
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
// s'executer plus tard (handler de click, async) sans crash.
const auth = useAuthStore()
const api = useApi()
const { t } = useI18n()
const { loadSidebar } = useSidebar()
/**
* Synchronise le state singleton depuis le store auth. A appeler au
* mount du SiteSelector (ou via un watcher sur `auth.user`).
*/
function syncFromAuth(): void {
availableSites.value = auth.user?.sites ?? []
currentSite.value = auth.user?.currentSite ?? null
}
/**
* Bascule le site courant. Optimistic UI : la mutation locale precede
* la requete HTTP. En cas d'echec (`api.patch` throw), l'etat local est
* restaure — le store auth n'a PAS ete muté a ce stade (la propagation
* `auth.setCurrentSite` se fait uniquement apres un succes HTTP), donc
* aucun rollback cote auth n'est necessaire.
*
* Garde anti-double-submit : si un switch est deja en vol, le second
* appel est un no-op silencieux.
*/
async function switchSite(site: Site): Promise<void> {
if (switching.value) {
return
}
const previousLocal = currentSite.value
currentSite.value = site
switching.value = true
try {
await api.patch(
'/me/current-site',
{ site: `/api/sites/${site.id}` },
{ toastSuccessMessage: t('sites.selector.switchSuccess') },
)
// Propage au store auth via l'action dediee — plus tracable que
// la mutation directe et garantit la notification des watchers.
// 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
} finally {
switching.value = false
}
}
/**
* Vide l'etat singleton. Appele au logout pour eviter qu'un user
* suivant (connecte sur le meme onglet) voie les sites de l'ancien.
*/
function resetCurrentSite(): void {
currentSite.value = null
availableSites.value = []
switching.value = false
}
return {
currentSite,
availableSites,
switching,
switchSite,
syncFromAuth,
resetCurrentSite,
}
}

View File

@@ -0,0 +1 @@
export default defineNuxtConfig({})

View File

@@ -0,0 +1,170 @@
<template>
<div>
<!-- En-tete -->
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.sites.title') }}
</h1>
<MalioButton
v-if="can('sites.manage')"
:label="t('admin.sites.newSite')"
icon-name="mdi:plus"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
<!-- Table des sites -->
<MalioDataTable
class="mt-6"
:columns="columns"
:items="siteItems"
:total-items="sites.length"
:row-clickable="canManage"
:empty-message="t('admin.sites.noSites')"
@row-click="onRowClick"
>
<template #cell-color="{ item }">
<span class="inline-flex items-center gap-2">
<span
:style="{ backgroundColor: item.color }"
class="inline-block size-5 rounded-full border border-neutral-200"
/>
<span class="font-mono text-xs">{{ item.color }}</span>
</span>
</template>
<template #cell-fullAddress="{ item }">
<span class="line-clamp-2 text-xs text-neutral-600">
{{ item.fullAddress }}
</span>
</template>
</MalioDataTable>
<!-- Drawer creation/edition -->
<SiteDrawer
v-model="drawerOpen"
:site="selectedSite"
@saved="onSiteSaved"
@delete="onDeleteRequest"
/>
<!-- Modale de suppression -->
<SiteDeleteModal
v-model="deleteModalOpen"
:site-name="siteToDelete?.name ?? ''"
:loading="deleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup lang="ts">
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)
const columns = [
{ key: 'name', label: t('admin.sites.table.name') },
{ key: 'city', label: t('admin.sites.table.city') },
{ key: 'postalCode', label: t('admin.sites.table.postalCode') },
{ key: 'color', label: t('admin.sites.table.color') },
{ key: 'fullAddress', label: t('admin.sites.table.fullAddress') },
]
// Transformer les sites en items compatibles MalioDataTable.
// `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 => ({
id: site.id,
name: site.name,
city: site.city,
postalCode: site.postalCode,
color: site.color,
fullAddress: site.fullAddress.split('\n').join(', '),
})),
)
function getSiteById(id: number): Site | undefined {
return sites.value.find(s => s.id === id)
}
function onRowClick(item: Record<string, unknown>) {
const site = getSiteById(item.id as number)
if (site) openEditDrawer(site)
}
const drawerOpen = ref(false)
const selectedSite = ref<Site | null>(null)
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
}
function openEditDrawer(site: Site) {
selectedSite.value = site
drawerOpen.value = true
}
function onDeleteRequest() {
if (!selectedSite.value) return
siteToDelete.value = selectedSite.value
deleteModalOpen.value = true
}
async function handleDelete() {
if (!siteToDelete.value) return
deleting.value = true
try {
await api.delete(`/sites/${siteToDelete.value.id}`, {}, {
toastSuccessMessage: t('admin.sites.toast.deleted'),
})
deleteModalOpen.value = false
siteToDelete.value = null
drawerOpen.value = false
await loadSites()
// 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()
}
onMounted(() => {
loadSites()
})
</script>