feat(sites) : barre de selection de site (ticket 3/4)

Barre horizontale en haut de l'app qui liste les sites autorises de
l'utilisateur et permet de switcher d'un click. Consomme le composant
MalioSiteSelector de @malio/layer-ui 1.4.0 (upgrade depuis 1.3.0).

Composables :
- useModules (shared) : consomme /api/modules, expose isModuleActive.
  Pattern aligne sur useSidebar.
- useCurrentSite (layer sites) : singleton state, switchSite optimistic
  avec rollback sur erreur, garde anti-double-submit, propagation au
  store auth via action setCurrentSite dediee.

Composant :
- SiteSelector.vue : wrapper thin autour de MalioSiteSelector. Texte
  blanc uniforme (conforme maquette Figma) avec taille 24px forcee via
  labelClass="text-2xl". aria-label du group via ariaGroupLabel i18n.

Integration :
- Middleware auth.global.ts : chargement parallele sidebar + modules.
- layouts/default.vue : render conditionnel si module Sites actif ET
  user.sites.length > 0.
- logout.vue : reset des 3 composables (sidebar, modules, currentSite)
  dans un try/finally.
- nuxt.config.ts : auto-detection des composables/ de chaque layer
  module (necessaire car imports.dirs explicite override les defaults
  Nuxt).

Couleurs fixtures finales : Chatellerault #056CF2, Saint-Jean #F3CB00,
Pommevic #74BF04. Charge aux admins de choisir des teintes foncees
(texte blanc non contrastable via calcul WCAG, design choisi).

Tests : 40 Vitest (color, useModules, useSidebar, useCurrentSite,
SiteSelector) incluant garde anti-regression pour useI18n hors setup.
182/182 PHPUnit backend, avec et sans module actif.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 11:45:48 +02:00
parent d137828919
commit 03c761eed4
23 changed files with 951 additions and 76 deletions

View File

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

View File

@@ -99,7 +99,7 @@
<script setup lang="ts">
import type { Site } from '~/shared/types/sites'
import { isValidSiteColor } from '~/modules/sites/utils/color'
import { isValidSiteColor } from '~/shared/utils/color'
const { t } = useI18n()
const api = useApi()

View File

@@ -0,0 +1,86 @@
<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
}
// Ignore les clics sur le site deja actif (pas de PATCH superflu).
if (currentSite.value && currentSite.value.id === target.id) return
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,176 @@
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)
// 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 ne declenche aucun PATCH', async () => {
const wrapper = mountSelector()
await wrapper.find('[data-testid="tile-1"]').trigger('click')
await flushPromises()
expect(mockPatch).not.toHaveBeenCalled()
})
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,211 @@
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,
}))
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,104 @@
/**
* 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'
const currentSite = ref<Site | null>(null)
const availableSites = ref<Site[]>([])
const switching = ref(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()
/**
* 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)
} 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

@@ -1,50 +0,0 @@
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('#10B981')).toBe(true)
expect(isValidSiteColor('#F59E0B')).toBe(true)
})
it('rejette un nom CSS', () => {
expect(isValidSiteColor('red')).toBe(false)
})
it('rejette un hex court (3 caracteres)', () => {
expect(isValidSiteColor('#FFF')).toBe(false)
})
it('rejette un hex sans diese', () => {
expect(isValidSiteColor('FFFFFF')).toBe(false)
})
it('rejette un format rgb()', () => {
expect(isValidSiteColor('rgb(255, 0, 0)')).toBe(false)
})
it('rejette un hex trop long', () => {
expect(isValidSiteColor('#1234567')).toBe(false)
})
it('rejette un caractere non hex', () => {
expect(isValidSiteColor('#12345G')).toBe(false)
})
it('rejette une chaine vide', () => {
expect(isValidSiteColor('')).toBe(false)
})
})

View File

@@ -1,15 +0,0 @@
/**
* Validation du format de couleur d'un site.
*
* Aligne sur la regex backend (Site entity) : seul le format #RRGGBB
* strict est accepte, avec 6 caracteres hexadecimaux apres le #.
* Tolere la casse (majuscules, minuscules, mixte).
*
* Utilise par SiteDrawer pour bloquer le submit cote front avant qu'une
* requete invalide parte au backend.
*/
const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/
export function isValidSiteColor(hex: string): boolean {
return HEX_COLOR_REGEX.test(hex)
}