| 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:
@@ -14,6 +14,7 @@
|
||||
</MalioSidebar>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<SiteSelector v-if="showSiteSelector"/>
|
||||
<main
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div
|
||||
@@ -30,8 +31,21 @@
|
||||
const {t} = useI18n()
|
||||
const ui = useUiStore()
|
||||
const {sections} = useSidebar()
|
||||
const {isModuleActive} = useModules()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
// Le SiteSelector est rendu si :
|
||||
// - le module Sites est actif dans config/modules.php (sinon la feature
|
||||
// n'a pas de sens, cf. ticket 3 spec criteres d'acceptation) ;
|
||||
// - ET l'user connecte a au moins un site autorise (sinon "barre vide"
|
||||
// sans tile cliquable).
|
||||
// Les deux flags sont resolus par le middleware auth.global.ts avant
|
||||
// que le layout ne soit rendu (plan load parallele), donc pas de flash.
|
||||
const showSiteSelector = computed(() =>
|
||||
isModuleActive('sites') && (auth.user?.sites?.length ?? 0) > 0,
|
||||
)
|
||||
|
||||
const translatedSections = computed(() =>
|
||||
sections.value.map(section => ({
|
||||
label: t(section.label),
|
||||
|
||||
@@ -15,9 +15,16 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
const { loaded, loadSidebar } = useSidebar()
|
||||
if (!loaded.value) {
|
||||
await loadSidebar()
|
||||
}
|
||||
const { loaded: sidebarLoaded, loadSidebar } = useSidebar()
|
||||
const { loaded: modulesLoaded, loadModules } = useModules()
|
||||
|
||||
// Chargement parallele sidebar + modules actifs : les deux sont
|
||||
// consommes par layouts/default.vue (sidebar pour la nav, modules
|
||||
// pour conditionner le SiteSelector). Charger en parallele evite
|
||||
// le flash au premier paint de la barre.
|
||||
await Promise.all([
|
||||
sidebarLoaded.value ? Promise.resolve() : loadSidebar(),
|
||||
modulesLoaded.value ? Promise.resolve() : loadModules(),
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs"
|
||||
"users": "Utilisateurs",
|
||||
"sites": "Sites"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -54,6 +55,15 @@
|
||||
"put": "Erreur lors de la mise a jour",
|
||||
"patch": "Erreur lors de la modification",
|
||||
"delete": "Erreur lors de la suppression"
|
||||
},
|
||||
"sites": {
|
||||
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"selector": {
|
||||
"ariaGroupLabel": "Sélecteur de site actif",
|
||||
"switchSuccess": "Site courant changé"
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
@@ -102,7 +112,8 @@
|
||||
"username": "Nom d'utilisateur",
|
||||
"admin": "Administrateur",
|
||||
"roles": "Roles",
|
||||
"directPermissions": "Permissions directes"
|
||||
"directPermissions": "Permissions directes",
|
||||
"sites": "Sites"
|
||||
},
|
||||
"drawer": {
|
||||
"title": "Permissions de {username}",
|
||||
@@ -110,6 +121,7 @@
|
||||
"adminToggle": "Administrateur (bypass total)",
|
||||
"rolesSection": "Rôles",
|
||||
"directPermissionsSection": "Permissions directes",
|
||||
"sitesSection": "Sites autorisés",
|
||||
"summarySection": "Résumé des permissions effectives",
|
||||
"noEffectivePermissions": "Aucune permission effective",
|
||||
"sourceRole": "via {role}",
|
||||
@@ -119,6 +131,39 @@
|
||||
"toast": {
|
||||
"updated": "Permissions mises à jour avec succès"
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"title": "Gestion des sites",
|
||||
"newSite": "Nouveau site",
|
||||
"editSite": "Modifier le site",
|
||||
"createSite": "Créer un site",
|
||||
"noSites": "Aucun site configuré",
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"city": "Ville",
|
||||
"postalCode": "Code postal",
|
||||
"color": "Couleur",
|
||||
"fullAddress": "Adresse complète"
|
||||
},
|
||||
"form": {
|
||||
"name": "Nom",
|
||||
"street": "Rue",
|
||||
"complement": "Complément d'adresse",
|
||||
"complementPlaceholder": "Bâtiment, escalier, BP... (optionnel)",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"color": "Couleur (format #RRGGBB)",
|
||||
"colorInvalid": "Format attendu : #RRGGBB (6 caractères hexadécimaux)"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer le site",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer le site \"{name}\" ? Cette action est irréversible et retirera ce site à tous les utilisateurs rattachés."
|
||||
},
|
||||
"toast": {
|
||||
"created": "Site créé avec succès",
|
||||
"updated": "Site mis à jour avec succès",
|
||||
"deleted": "Site supprimé avec succès"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
76
frontend/modules/sites/components/SiteDeleteModal.vue
Normal file
76
frontend/modules/sites/components/SiteDeleteModal.vue
Normal 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>
|
||||
185
frontend/modules/sites/components/SiteDrawer.vue
Normal file
185
frontend/modules/sites/components/SiteDrawer.vue
Normal 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>
|
||||
92
frontend/modules/sites/components/SiteSelector.vue
Normal file
92
frontend/modules/sites/components/SiteSelector.vue
Normal 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>
|
||||
189
frontend/modules/sites/components/__tests__/SiteSelector.spec.ts
Normal file
189
frontend/modules/sites/components/__tests__/SiteSelector.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
})
|
||||
})
|
||||
130
frontend/modules/sites/composables/useCurrentSite.ts
Normal file
130
frontend/modules/sites/composables/useCurrentSite.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
1
frontend/modules/sites/nuxt.config.ts
Normal file
1
frontend/modules/sites/nuxt.config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
170
frontend/modules/sites/pages/admin/sites.vue
Normal file
170
frontend/modules/sites/pages/admin/sites.vue
Normal 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>
|
||||
@@ -3,11 +3,21 @@ import { resolve } from 'node:path'
|
||||
|
||||
// Auto-detect module layers: every directory under frontend/modules/ becomes a Nuxt layer.
|
||||
const modulesDir = resolve(__dirname, 'modules')
|
||||
const moduleLayers = existsSync(modulesDir)
|
||||
const moduleDirs = existsSync(modulesDir)
|
||||
? readdirSync(modulesDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => `./modules/${d.name}`)
|
||||
.map(d => d.name)
|
||||
: []
|
||||
const moduleLayers = moduleDirs.map(name => `./modules/${name}`)
|
||||
|
||||
// Auto-detect composables dirs pour chaque layer module. Necessaire car le
|
||||
// `imports.dirs` explicite ci-dessous override le comportement par defaut
|
||||
// de Nuxt (qui scannerait composables/ de chaque layer automatiquement).
|
||||
// Sans ca, useCurrentSite / autres composables des modules ne seraient pas
|
||||
// resolus a l'execution — cf. ticket 3 bug detecte apres review.
|
||||
const moduleComposableDirs = moduleDirs
|
||||
.map(name => `./modules/${name}/composables`)
|
||||
.filter(path => existsSync(resolve(__dirname, path)))
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
@@ -51,6 +61,7 @@ export default defineNuxtConfig({
|
||||
'shared/composables',
|
||||
'shared/utils',
|
||||
'shared/stores',
|
||||
...moduleComposableDirs,
|
||||
],
|
||||
},
|
||||
vite: {
|
||||
|
||||
72
frontend/package-lock.json
generated
72
frontend/package-lock.json
generated
@@ -7,7 +7,7 @@
|
||||
"name": "coltura-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.3.0",
|
||||
"@malio/layer-ui": "^1.4.2",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -22,6 +22,7 @@
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
@@ -83,6 +84,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -580,27 +582,6 @@
|
||||
"integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
@@ -1839,9 +1820,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.3.0/layer-ui-1.3.0.tgz",
|
||||
"integrity": "sha512-Gs4pnlWTWrhoF3QQKxYBu4IxN65O9B4bls7s+ONm05qvI2Y2x7N4VNFGjWvT+rNQ4BzHFCxSCzN4V3o6p0Q7uw==",
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.2/layer-ui-1.4.2.tgz",
|
||||
"integrity": "sha512-H8f5FJXHFH9ZI1Jx4u9XE7w6VlR/d9Zr2encfQyMax1I0UZ3SiGBUjictcL33r0OhgsrgSmPq0J9aF6aab85Nw==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -2186,6 +2167,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz",
|
||||
"integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"c12": "^3.3.3",
|
||||
"consola": "^3.4.2",
|
||||
@@ -2288,6 +2270,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz",
|
||||
"integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "^3.5.30",
|
||||
"defu": "^6.1.4",
|
||||
@@ -3957,9 +3940,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/plugin-alias": {
|
||||
@@ -4628,6 +4611,7 @@
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
@@ -4690,6 +4674,7 @@
|
||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
@@ -5206,12 +5191,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
|
||||
"integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==",
|
||||
"version": "6.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
|
||||
"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "1.0.0-rc.2"
|
||||
"@rolldown/pluginutils": "1.0.0-rc.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
@@ -5469,6 +5454,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
|
||||
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.2",
|
||||
"@vue/compiler-core": "3.5.32",
|
||||
@@ -5712,6 +5698,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6099,6 +6086,7 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -6296,6 +6284,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -6410,6 +6399,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -6604,7 +6594,8 @@
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/clean-regexp": {
|
||||
"version": "1.0.0",
|
||||
@@ -7657,6 +7648,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8815,6 +8807,7 @@
|
||||
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
@@ -11205,6 +11198,7 @@
|
||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz",
|
||||
"integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dxup/nuxt": "^0.4.0",
|
||||
"@nuxt/cli": "^3.34.0",
|
||||
@@ -12263,6 +12257,7 @@
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
@@ -12314,6 +12309,7 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
||||
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.112.0"
|
||||
},
|
||||
@@ -12580,6 +12576,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
@@ -12658,6 +12655,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -13201,6 +13199,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -13820,6 +13819,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -14717,6 +14717,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -15372,6 +15373,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@@ -15638,6 +15640,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16556,6 +16559,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
|
||||
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.32",
|
||||
"@vue/compiler-sfc": "3.5.32",
|
||||
@@ -16600,6 +16604,7 @@
|
||||
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"eslint-scope": "^8.2.0 || ^9.0.0",
|
||||
@@ -16636,6 +16641,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.1.tgz",
|
||||
"integrity": "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.3.1",
|
||||
"@intlify/devtools-types": "11.3.1",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.3.0",
|
||||
"@malio/layer-ui": "^1.4.2",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -30,6 +30,7 @@
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
|
||||
71
frontend/shared/composables/__tests__/useModules.test.ts
Normal file
71
frontend/shared/composables/__tests__/useModules.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useModules } from '../useModules'
|
||||
|
||||
// Mock de useApi : on peut scripter la reponse de /api/modules.
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
|
||||
// useApi est auto-importe par Nuxt en prod. En Vitest isole, on expose le
|
||||
// mock comme global pour que l'appel sans import dans useModules.ts
|
||||
// (pattern aligne sur useSidebar) fonctionne.
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
describe('useModules', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
// Reset l'etat singleton entre tests.
|
||||
const { resetModules } = useModules()
|
||||
resetModules()
|
||||
})
|
||||
|
||||
it('charge la liste des modules actifs depuis /api/modules', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, activeModuleIds, loaded } = useModules()
|
||||
|
||||
await loadModules()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith('/modules', {}, { toast: false })
|
||||
expect(activeModuleIds.value).toEqual(['core', 'sites'])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('isModuleActive retourne true pour un id present', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, isModuleActive } = useModules()
|
||||
await loadModules()
|
||||
|
||||
expect(isModuleActive('sites')).toBe(true)
|
||||
expect(isModuleActive('core')).toBe(true)
|
||||
})
|
||||
|
||||
it('isModuleActive retourne false pour un id absent', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core'] })
|
||||
const { loadModules, isModuleActive } = useModules()
|
||||
await loadModules()
|
||||
|
||||
expect(isModuleActive('sites')).toBe(false)
|
||||
expect(isModuleActive('inexistant')).toBe(false)
|
||||
})
|
||||
|
||||
it('swallow les erreurs reseau et laisse la liste vide', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const { loadModules, activeModuleIds, loaded, isModuleActive } = useModules()
|
||||
|
||||
await loadModules()
|
||||
|
||||
expect(activeModuleIds.value).toEqual([])
|
||||
expect(loaded.value).toBe(true)
|
||||
expect(isModuleActive('sites')).toBe(false)
|
||||
})
|
||||
|
||||
it('resetModules vide l\'etat', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, resetModules, activeModuleIds, loaded } = useModules()
|
||||
await loadModules()
|
||||
expect(activeModuleIds.value.length).toBeGreaterThan(0)
|
||||
|
||||
resetModules()
|
||||
|
||||
expect(activeModuleIds.value).toEqual([])
|
||||
expect(loaded.value).toBe(false)
|
||||
})
|
||||
})
|
||||
71
frontend/shared/composables/__tests__/useSidebar.test.ts
Normal file
71
frontend/shared/composables/__tests__/useSidebar.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useSidebar } from '../useSidebar'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests de l'invariant "loadSidebar ne reject jamais".
|
||||
*
|
||||
* Garantie utilisee par le middleware auth.global.ts qui fait un
|
||||
* Promise.all([loadSidebar(), loadModules()]) — si l'un throw, le
|
||||
* middleware echoue et toute l'app avec. Le swallow interne est donc
|
||||
* load-bearing et ce test le verrouille.
|
||||
*/
|
||||
describe('useSidebar', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
const { resetSidebar } = useSidebar()
|
||||
resetSidebar()
|
||||
})
|
||||
|
||||
it('charge sections et disabledRoutes depuis /api/sidebar', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
sections: [{ label: 's', icon: 'i', items: [] }],
|
||||
disabledRoutes: ['/foo'],
|
||||
})
|
||||
const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar()
|
||||
|
||||
await loadSidebar()
|
||||
|
||||
expect(sections.value).toHaveLength(1)
|
||||
expect(disabledRoutes.value).toEqual(['/foo'])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('swallow les erreurs reseau sans rejeter (invariant middleware)', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar()
|
||||
|
||||
// Assertion principale : la promise resout normalement meme sur erreur.
|
||||
await expect(loadSidebar()).resolves.toBeUndefined()
|
||||
expect(sections.value).toEqual([])
|
||||
expect(disabledRoutes.value).toEqual([])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('isRouteDisabled matche exactement un chemin', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ sections: [], disabledRoutes: ['/foo'] })
|
||||
const { loadSidebar, isRouteDisabled } = useSidebar()
|
||||
await loadSidebar()
|
||||
|
||||
expect(isRouteDisabled('/foo')).toBe(true)
|
||||
expect(isRouteDisabled('/foo/bar')).toBe(true)
|
||||
expect(isRouteDisabled('/other')).toBe(false)
|
||||
})
|
||||
|
||||
it('resetSidebar vide l\'etat', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
sections: [{ label: 's', icon: 'i', items: [] }],
|
||||
disabledRoutes: ['/foo'],
|
||||
})
|
||||
const { loadSidebar, resetSidebar, sections, loaded } = useSidebar()
|
||||
await loadSidebar()
|
||||
expect(loaded.value).toBe(true)
|
||||
|
||||
resetSidebar()
|
||||
|
||||
expect(sections.value).toEqual([])
|
||||
expect(loaded.value).toBe(false)
|
||||
})
|
||||
})
|
||||
49
frontend/shared/composables/useModules.ts
Normal file
49
frontend/shared/composables/useModules.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Composable de lecture des modules actifs (source : `/api/modules`).
|
||||
*
|
||||
* State singleton au niveau module : `useSidebar` suit la meme convention.
|
||||
* Chargement idempotent via le flag `loaded`, reset explicite au logout
|
||||
* (voir pages/logout.vue).
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeModuleIds = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useModules() {
|
||||
async function loadModules() {
|
||||
try {
|
||||
const api = useApi()
|
||||
const data = await api.get<{ modules: string[] }>(
|
||||
'/modules',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
activeModuleIds.value = data.modules ?? []
|
||||
loaded.value = true
|
||||
} catch {
|
||||
// Swallow volontaire aligne sur useSidebar : un echec reseau ne
|
||||
// doit pas bloquer le rendu, l'app affichera juste sans la
|
||||
// granularite module (selector masque par defaut).
|
||||
activeModuleIds.value = []
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function isModuleActive(id: string): boolean {
|
||||
return activeModuleIds.value.includes(id)
|
||||
}
|
||||
|
||||
function resetModules() {
|
||||
activeModuleIds.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
activeModuleIds,
|
||||
loaded,
|
||||
loadModules,
|
||||
isModuleActive,
|
||||
resetModules,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import type { SidebarSection } from '~/shared/types'
|
||||
|
||||
const sections = ref<SidebarSection[]>([])
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { UserData } from '~/shared/types/user-data'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { getCurrentUser, login, logout } from '~/shared/services/auth'
|
||||
|
||||
/**
|
||||
* Callbacks enregistres par les composables singletons qui doivent
|
||||
* reinitialiser leur etat quand la session est invalidee (ex: expiration
|
||||
* JWT, logout depuis un intercepteur 401). Utilise le pattern
|
||||
* "callback registration" (Option C) pour eviter une dependance croisee
|
||||
* depuis shared/ vers modules/ — chaque composable s'auto-enregistre.
|
||||
*/
|
||||
const onSessionClearedCallbacks: Array<() => void> = []
|
||||
|
||||
/**
|
||||
* Enregistre un callback a invoquer lorsque clearSession() est appelee.
|
||||
* Typiquement invoque au setup-time du composable (module-level), donc
|
||||
* une seule fois par instance de composable singleton.
|
||||
*/
|
||||
export function onAuthSessionCleared(cb: () => void): void {
|
||||
onSessionClearedCallbacks.push(cb)
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: null as UserData | null,
|
||||
@@ -16,6 +35,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.user = null
|
||||
this.checked = true
|
||||
this.isLoading = false
|
||||
// Notifie les composables singletons (useCurrentSite, etc.) afin
|
||||
// qu'ils reinitialisation leur etat — necessaire quand la session
|
||||
// est invalidee par un intercepteur 401 sans passer par logout.vue.
|
||||
onSessionClearedCallbacks.forEach((cb) => cb())
|
||||
},
|
||||
async ensureSession() {
|
||||
if (this.checked) {
|
||||
@@ -66,6 +89,18 @@ export const useAuthStore = defineStore('auth', {
|
||||
} catch {
|
||||
// Silently fail — user session might have expired
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Action dediee au switch du site courant (ticket 3 module Sites).
|
||||
* Utilisee par useCurrentSite apres la confirmation serveur, et en
|
||||
* rollback si la requete PATCH echoue apres une mutation optimistic.
|
||||
* Passer explicitement par une action plutot que muter user.currentSite
|
||||
* directement garantit la tracabilite Pinia (devtools).
|
||||
*/
|
||||
setCurrentSite(site: Site | null) {
|
||||
if (this.user) {
|
||||
this.user.currentSite = site
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface UserListItem {
|
||||
isAdmin: boolean
|
||||
roles: string[]
|
||||
directPermissions: string[]
|
||||
/** IRIs des sites autorises (ticket 2 module Sites). */
|
||||
sites: string[]
|
||||
}
|
||||
|
||||
export interface EffectivePermission {
|
||||
|
||||
24
frontend/shared/types/sites.ts
Normal file
24
frontend/shared/types/sites.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface Site {
|
||||
id: number
|
||||
name: string
|
||||
street: string
|
||||
complement: string | null
|
||||
postalCode: string
|
||||
city: string
|
||||
color: string
|
||||
/** Adresse complete reconstituee cote backend (getter computed). Lecture seule. */
|
||||
fullAddress: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en POST/PATCH /api/sites. Volontairement sans `fullAddress`
|
||||
* (computed cote backend) ni champs read-only (id, timestamps).
|
||||
*/
|
||||
export interface SiteInput {
|
||||
name: string
|
||||
street: string
|
||||
complement: string | null
|
||||
postalCode: string
|
||||
city: string
|
||||
color: string
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Site } from './sites'
|
||||
|
||||
export interface UserData {
|
||||
id: number
|
||||
username: string
|
||||
@@ -6,4 +8,8 @@ export interface UserData {
|
||||
isAdmin: boolean
|
||||
/** Codes de permission effectifs de l'utilisateur, tries alphabetiquement, sans doublon. */
|
||||
effectivePermissions: string[]
|
||||
/** Sites autorises pour l'utilisateur (ticket 2 module Sites). */
|
||||
sites: Site[]
|
||||
/** Site actuellement selectionne par l'utilisateur, ou null si aucun. */
|
||||
currentSite: Site | null
|
||||
}
|
||||
|
||||
42
frontend/shared/utils/__tests__/color.test.ts
Normal file
42
frontend/shared/utils/__tests__/color.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isValidSiteColor } from '../color'
|
||||
|
||||
describe('isValidSiteColor', () => {
|
||||
it('accepte un hex majuscule', () => {
|
||||
expect(isValidSiteColor('#ABCDEF')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte un hex minuscule', () => {
|
||||
expect(isValidSiteColor('#abcdef')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte un hex mixte', () => {
|
||||
expect(isValidSiteColor('#0a1B2c')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte les couleurs fixtures du projet', () => {
|
||||
expect(isValidSiteColor('#056CF2')).toBe(true)
|
||||
expect(isValidSiteColor('#F3CB00')).toBe(true)
|
||||
expect(isValidSiteColor('#74BF04')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejette un nom CSS', () => {
|
||||
expect(isValidSiteColor('red')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un hex court', () => {
|
||||
expect(isValidSiteColor('#FFF')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un hex sans diese', () => {
|
||||
expect(isValidSiteColor('FFFFFF')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un caractere non hex', () => {
|
||||
expect(isValidSiteColor('#12345G')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette une chaine vide', () => {
|
||||
expect(isValidSiteColor('')).toBe(false)
|
||||
})
|
||||
})
|
||||
19
frontend/shared/utils/color.ts
Normal file
19
frontend/shared/utils/color.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Utilitaires de couleur partages.
|
||||
*
|
||||
* Aligne sur la regex backend stricte #RRGGBB (voir Site.php).
|
||||
*/
|
||||
|
||||
const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/
|
||||
|
||||
/**
|
||||
* Valide qu'une chaine respecte le format #RRGGBB strict (7 caracteres,
|
||||
* 6 chiffres hexadecimaux apres le #). Tolere la casse (majuscules,
|
||||
* minuscules, mixte).
|
||||
*
|
||||
* Utilise cote front par SiteDrawer pour bloquer le submit avant l'envoi
|
||||
* backend — miroir du pattern Symfony Assert\Regex sur Site::$color.
|
||||
*/
|
||||
export function isValidSiteColor(hex: string): boolean {
|
||||
return HEX_COLOR_REGEX.test(hex)
|
||||
}
|
||||
@@ -1,13 +1,26 @@
|
||||
import type {Config} from 'tailwindcss'
|
||||
|
||||
/**
|
||||
* Config Tailwind du projet Coltura.
|
||||
*
|
||||
* @nuxtjs/tailwindcss merge automatiquement les configs de chaque layer
|
||||
* Nuxt declare dans `nuxt.config.ts:extends`. Le layer `@malio/layer-ui`
|
||||
* apporte deja :
|
||||
* - borderRadius.malio (var CSS --m-radius)
|
||||
* - colors.m.{primary,surface,border,text,muted,bg,disabled,danger,
|
||||
* success,btn-*,site-blue,site-yellow,site-green}
|
||||
* - fontFamily.sans (Helvetica Neue)
|
||||
*
|
||||
* Cette config locale ne redeclare QUE ce qui est specifique a Coltura
|
||||
* ou absent de la config Malio — evite la duplication et les derives.
|
||||
*/
|
||||
export default <Partial<Config>>{
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif']
|
||||
},
|
||||
colors: {
|
||||
// Couleurs applicatives Coltura (hors namespace `m` reserve
|
||||
// au design system Malio partage).
|
||||
primary: {
|
||||
500: '#222783',
|
||||
},
|
||||
@@ -20,27 +33,10 @@ export default <Partial<Config>>{
|
||||
blue: {
|
||||
500: '#056CF2'
|
||||
},
|
||||
// Extensions au namespace `m` non couvertes par Malio 1.4.1.
|
||||
m: {
|
||||
primary: 'rgb(var(--m-primary) / <alpha-value>)',
|
||||
secondary: 'rgb(var(--m-secondary, 75 77 237) / <alpha-value>)',
|
||||
tertiary: 'rgb(var(--m-tertiary, 243 244 248) / <alpha-value>)',
|
||||
border: 'rgb(var(--m-border) / <alpha-value>)',
|
||||
text: 'rgb(var(--m-text) / <alpha-value>)',
|
||||
muted: 'rgb(var(--m-muted) / <alpha-value>)',
|
||||
bg: 'rgb(var(--m-bg) / <alpha-value>)',
|
||||
surface: 'rgb(var(--m-surface) / <alpha-value>)',
|
||||
disabled: 'rgb(var(--m-disabled) / <alpha-value>)',
|
||||
danger: 'rgb(var(--m-danger) / <alpha-value>)',
|
||||
success: 'rgb(var(--m-success) / <alpha-value>)',
|
||||
'btn-primary': 'rgb(var(--m-btn-primary) / <alpha-value>)',
|
||||
'btn-primary-hover': 'rgb(var(--m-btn-primary-hover) / <alpha-value>)',
|
||||
'btn-primary-active': 'rgb(var(--m-btn-primary-active) / <alpha-value>)',
|
||||
'btn-secondary': 'rgb(var(--m-btn-secondary) / <alpha-value>)',
|
||||
'btn-secondary-hover': 'rgb(var(--m-btn-secondary-hover) / <alpha-value>)',
|
||||
'btn-secondary-active': 'rgb(var(--m-btn-secondary-active) / <alpha-value>)',
|
||||
'btn-danger': 'rgb(var(--m-btn-danger) / <alpha-value>)',
|
||||
'btn-danger-hover': 'rgb(var(--m-btn-danger-hover) / <alpha-value>)',
|
||||
'btn-danger-active': 'rgb(var(--m-btn-danger-active) / <alpha-value>)',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true,
|
||||
|
||||
Reference in New Issue
Block a user