11import type { BadgeItem , MenuItem } from '~/constants/types'
2- import { useIdentityStates } from './useIdentityStates'
3- import { DefaultMenuParts , MaxMenuBadgeCount , MenuPart } from '~/constants/variables'
4- import { IdentityState } from '~/constants/enums'
2+ import { DefaultMenuParts , MaxMenuBadgeCount } from '~/constants/variables'
53import qs from 'qs'
64import type { useIdentityStateStore } from '~/stores/identityState'
5+ import { getDefaultMenuEntries } from '~/constants/defaultMenuEntries'
76import { sort } from 'radash'
87
9- const { getStateBadge } = useIdentityStates ( )
10- const config = useAppConfig ( )
8+ const config = useAppConfig ( ) as any
119
1210type useMenuReturnType = {
1311 getMenu : ( ) => MenuItem [ ]
@@ -23,225 +21,122 @@ type MenuPartItem = {
2321
2422function useMenu ( identityStateStore : ReturnType < typeof useIdentityStateStore > ) : useMenuReturnType {
2523 const menuParts = ref < MenuPartItem [ ] > ( DefaultMenuParts )
24+ const useDefaultEntries = config ?. menus ?. useDefaultEntries !== false
2625 const menuEntries = ( config ?. menus ?. entries as any [ ] ) || [ ]
2726 const { hasPermission, hasPermissionStartsWith } = useAccessControl ( )
2827
29- if ( config ?. menus ?. parts ) {
30- menuParts . value = sort ( [ ...menuParts . value , ...( config . menus ?. parts as any ) || [ ] ] , ( part ) => {
31- const pos = part . position || 9_999
32- return pos
33- } )
28+ function normalizeNameFromLabel ( label : string ) : string {
29+ // Auto-remplissage côté déploiement :
30+ // - minuscules
31+ // - espaces -> `_`
32+ // - le reste est conservé tel quel
33+ return label . toLowerCase ( ) . trim ( ) . replace ( / \s + / g, '_' )
3434 }
3535
36- const menus = ref < MenuItem [ ] > ( [
37- {
38- icon : 'mdi-account' ,
39- label : 'Liste des identités' ,
40- path : '/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0' ,
41- color : 'primary' ,
42- badge : { color : 'primary' } ,
43- part : MenuPart . DONNEES ,
44- hideInMenuBar : false ,
45- _acl : '/management/identities' ,
46- } ,
47- {
48- icon : 'mdi-download-outline' ,
49- label : 'Exporter' ,
50- path : '/identities/export' ,
51- color : 'accent' ,
52- part : MenuPart . DONNEES ,
53- hideInMenuBar : true ,
54- _acl : '/management/identities' ,
55- } , {
56- icon : 'mdi-book-clock' ,
57- label : 'Journal des jobs' ,
58- path : '/jobs/table?filters[:state]=-1' ,
59- color : 'info' ,
60- part : MenuPart . DONNEES ,
61- hideInMenuBar : false ,
62- _acl : '/core/jobs' ,
63- } , {
64- icon : 'mdi-timeline-clock-outline' ,
65- label : 'Cycle de vie des identités' ,
66- path : '/lifecycles/table' ,
67- color : 'info' ,
68- part : MenuPart . DONNEES ,
69- hideInMenuBar : false ,
70- _acl : '/management/lifecycle' ,
71- } ,
72- {
73- icon : 'mdi-clipboard-text-clock' ,
74- label : 'Historique des changements' ,
75- path : '/audits/table' ,
76- color : 'lime-8' ,
77- hideInMenuBar : false ,
78- part : MenuPart . DONNEES ,
79- _acl : '/core/audits' ,
80- } ,
81- {
82- icon : 'mdi-set-merge' ,
83- label : 'Detection des doublons' ,
84- path : '/identities/fusion' ,
85- color : 'positive' ,
86- part : MenuPart . DONNEES ,
87- hideInMenuBar : true ,
88- _acl : '/management/identities' ,
89- } ,
90- {
91- icon : 'mdi-trash-can' ,
92- label : 'Corbeille' ,
93- path : '/identities/trash' ,
94- color : 'grey-10' ,
95- hideInMenuBar : true ,
96- part : MenuPart . DONNEES ,
97- _acl : '/management/identities' ,
98- } ,
99- {
100- icon : 'mdi-account-check' ,
101- label : 'A valider' ,
102- path : `/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[#state]=${ IdentityState . TO_VALIDATE } ` ,
103- color : 'warning' ,
104- textColor : 'black' ,
105- badge : getStateBadge ( IdentityState . TO_VALIDATE ) ,
106- part : MenuPart . ETATS ,
107- hideInMenuBar : false ,
108- _acl : '/management/identities' ,
109- } ,
110- {
111- icon : 'mdi-account-alert' ,
112- label : 'A compléter' ,
113- path : `/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${ IdentityState . TO_COMPLETE } ` ,
114- color : 'secondary' ,
115- textColor : 'black' ,
116- badge : getStateBadge ( IdentityState . TO_COMPLETE ) ,
117- part : MenuPart . ETATS ,
118- hideInMenuBar : false ,
119- _acl : '/management/identities' ,
120- } ,
121- {
122- icon : 'mdi-sync' ,
123- label : 'A synchroniser' ,
124- path : `/identities/table?readonly=1&sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${ IdentityState . TO_SYNC } ` ,
125- color : 'orange-8' ,
126- badge : getStateBadge ( IdentityState . TO_SYNC ) ,
127- part : MenuPart . ETATS ,
128- hideInMenuBar : false ,
129- _acl : '/management/identities' ,
130- } ,
131- {
132- icon : 'mdi-loading' ,
133- label : 'En cours de synchro.' ,
134- path : `/identities/table?readonly=1&sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${ IdentityState . PROCESSING } ` ,
135- color : 'grey-8' ,
136- badge : getStateBadge ( IdentityState . PROCESSING ) ,
137- part : MenuPart . ETATS ,
138- hideInMenuBar : false ,
139- _acl : '/management/identities' ,
140- } ,
141- {
142- icon : 'mdi-check' ,
143- label : 'Synchronisées' ,
144- path : `/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${ IdentityState . SYNCED } ` ,
145- badge : getStateBadge ( IdentityState . SYNCED ) ,
146- color : 'positive' ,
147- part : MenuPart . ETATS ,
148- hideInMenuBar : false ,
149- _acl : '/management/identities' ,
150- } ,
151- {
152- icon : 'mdi-account-switch-outline' ,
153- label : 'Fusionnées' ,
154- path : '/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[~primaryEmployeeNumber]=true' ,
155- color : 'linear-gradient(135deg, #7C3AED 0%, #EC4899 50%, #F97316 100%)' ,
156- textColor : 'white' ,
157- badge : { color : 'linear-gradient(135deg, #7C3AED 0%, #EC4899 50%, #F97316 100%)' , textColor : 'white' } ,
158- part : MenuPart . ETATS ,
159- hideInMenuBar : false ,
160- _acl : '/management/identities' ,
161- } ,
162- ...menuEntries
163- . filter ( ( entry : any ) => {
164- if ( entry . _acl ) {
165- return hasPermissionStartsWith ( [ entry . _acl ] )
36+ if ( config ?. menus ?. parts ) {
37+ const overrideParts = config . menus . parts as any [ ]
38+ const mergedParts : MenuPartItem [ ] = [ ...menuParts . value ]
39+
40+ for ( const overridePart of overrideParts ) {
41+ const overrideLabel = typeof overridePart ?. label === 'string' ? overridePart . label : ''
42+ if ( ! overrideLabel ) continue
43+
44+ const matchIndex = mergedParts . findIndex ( ( part ) => part . label === overrideLabel )
45+ if ( matchIndex >= 0 ) {
46+ mergedParts [ matchIndex ] = {
47+ ...mergedParts [ matchIndex ] ,
48+ ...( overridePart as any ) ,
16649 }
50+ } else {
51+ mergedParts . push ( overridePart as any )
52+ }
53+ }
54+
55+ menuParts . value = sort ( mergedParts , ( part ) => part . position || 9_999 )
56+ }
57+
58+ const defaultMenuEntries = getDefaultMenuEntries ( )
59+
60+ // Fusion des menus : les entrées YAML peuvent surcharger (override) les entrées par défaut.
61+ // Override :
62+ // - priorité au match par `path` (exact)
63+ // - sinon match par `name` (fourni ou dérivé du `label`)
64+ // - sinon match par `label` (normalisé, pour compat)
65+ // - si aucune entrée ne correspond : ajout (placé avant "En erreur" si possible)
66+ const baseMenuEntries = useDefaultEntries ? defaultMenuEntries : [ ]
67+ const insertionIndex = baseMenuEntries . findIndex ( ( m ) => m . label === 'En erreur' )
68+
69+ const mergedMenuEntries : MenuItem [ ] = [ ...baseMenuEntries ]
70+ const unmatchedOverrides : MenuItem [ ] = [ ]
71+
72+ for ( const overrideEntry of menuEntries as any [ ] ) {
73+ const overridePath = typeof overrideEntry ?. path === 'string' ? overrideEntry . path . trim ( ) : ''
74+ const overrideLabel = typeof overrideEntry ?. label === 'string' ? overrideEntry . label : ''
75+ const overrideName =
76+ typeof overrideEntry ?. name === 'string'
77+ ? normalizeNameFromLabel ( overrideEntry . name )
78+ : overrideLabel
79+ ? normalizeNameFromLabel ( overrideLabel )
80+ : ''
81+
82+ const matchIndexByPath = overridePath ? mergedMenuEntries . findIndex ( ( m ) => m . path === overridePath ) : - 1
83+ const matchIndexByName =
84+ matchIndexByPath === - 1 && overrideName
85+ ? mergedMenuEntries . findIndex ( ( m ) => typeof m . name === 'string' && normalizeNameFromLabel ( m . name ) === overrideName )
86+ : - 1
87+
88+ const matchIndexByLabel =
89+ matchIndexByPath === - 1 && matchIndexByName === - 1 && ! overrideName && overrideLabel
90+ ? mergedMenuEntries . findIndex ( ( m ) => normalizeLabel ( m . label ) === normalizeLabel ( overrideLabel ) )
91+ : - 1
92+
93+ const matchIndex = matchIndexByPath >= 0 ? matchIndexByPath : matchIndexByName >= 0 ? matchIndexByName : matchIndexByLabel
94+
95+ if ( matchIndex >= 0 ) {
96+ mergedMenuEntries [ matchIndex ] = {
97+ ...mergedMenuEntries [ matchIndex ] ,
98+ ...( overrideEntry as any ) ,
99+ }
100+ } else {
101+ unmatchedOverrides . push ( overrideEntry as any )
102+ }
103+ }
104+
105+ if ( unmatchedOverrides . length > 0 ) {
106+ const indexToInsert = insertionIndex >= 0 ? insertionIndex : mergedMenuEntries . length
107+ mergedMenuEntries . splice ( indexToInsert , 0 , ...unmatchedOverrides )
108+ }
109+
110+ // `name` est optionnel dans `menus.yml`. On l'auto-remplit sinon.
111+ for ( let i = 0 ; i < mergedMenuEntries . length ; i ++ ) {
112+ const entry = mergedMenuEntries [ i ]
113+ if ( typeof ( entry as any ) ?. name === 'string' && ( entry as any ) . name . trim ( ) ) continue
114+ if ( typeof entry . label !== 'string' || ! entry . label . trim ( ) ) continue
115+ ; ( mergedMenuEntries [ i ] as any ) . name = normalizeNameFromLabel ( entry . label )
116+ }
117+
118+ const menus = ref < MenuItem [ ] > (
119+ mergedMenuEntries . filter ( ( entry ) => {
120+ // Si `_acl` est fourni, on laisse `getMenu()` gérer l'autorisation exacte.
121+ if ( entry . _acl ) return true
122+ if ( ! entry . path ) return false
123+
124+ const basePath = entry . path . replace ( / ^ \/ / , '' )
125+ const pathWithoutQuery = basePath . split ( '?' ) [ 0 ] || ''
126+ const uri = pathWithoutQuery . split ( '/' ) [ 0 ] || ''
167127
168- const basePath = entry . path . replace ( / ^ \/ / , '' )
169- const path = basePath . split ( '?' ) [ 0 ] || ''
170- const uri = path [ 0 ] . split ( '/' ) [ 0 ] || ''
171-
172- return hasPermissionStartsWith ( [ uri ] )
173- } ) || [ ] ,
174- {
175- icon : 'mdi-account-remove' ,
176- label : 'En erreur' ,
177- path : `/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${ IdentityState . ON_ERROR } ` ,
178- color : 'negative' ,
179- badge : getStateBadge ( IdentityState . ON_ERROR ) ,
180- part : MenuPart . ETATS ,
181- hideInMenuBar : false ,
182- _acl : '/management/identities' ,
183- } ,
184- {
185- icon : 'mdi-publish-off' ,
186- label : 'À ne pas synchroniser' ,
187- path : `/identities/table?sort[metadata.lastUpdatedAt]=desc&skip=0&filters[%23state]=${ IdentityState . DONT_SYNC } ` ,
188- color : 'black' ,
189- badge : getStateBadge ( IdentityState . DONT_SYNC ) ,
190- part : MenuPart . ETATS ,
191- hideInMenuBar : false ,
192- _acl : '/management/identities' ,
193- } ,
194- {
195- icon : 'mdi-email-alert' ,
196- label : 'Invitations non envoyées' ,
197- path : '/identities/table?limit=10&skip=0&filters[%23initState]=0&sort[metadata.lastUpdatedAt]=desc' ,
198- color : 'negative' ,
199- part : MenuPart . ACTIVATION ,
200- badge : { color : 'negative' } ,
201- hideInMenuBar : false ,
202- _acl : '/management/identities' ,
203- } ,
204- {
205- icon : 'mdi-email-fast' ,
206- label : 'Invitations envoyées' ,
207- path : '/identities/table?limit=10&skip=0&filters[%23initState]=1&sort[metadata.lastUpdatedAt]=desc' ,
208- color : 'warning' ,
209- textColor : 'black' ,
210- part : MenuPart . ACTIVATION ,
211- badge : { color : 'warning' , textColor : 'black' } ,
212- hideInMenuBar : false ,
213- _acl : '/management/identities' ,
214- } ,
215- {
216- icon : 'mdi-email-open' ,
217- label : 'Comptes activés' ,
218- path : '/identities/table?limit=10&skip=0&filters[%23initState]=2&sort[metadata.lastUpdatedAt]=desc' ,
219- color : 'positive' ,
220- textColor : 'white' ,
221- part : MenuPart . ACTIVATION ,
222- badge : { color : 'positive' } ,
223- hideInMenuBar : false ,
224- _acl : '/management/identities' ,
225- } ,
226- {
227- icon : 'mdi-email-remove' ,
228- label : 'Invitations périmées' ,
229- path : '/identities/outdated' ,
230- color : 'accent' ,
231- part : MenuPart . ACTIVATION ,
232- hideInMenuBar : false ,
233- _acl : '/management/identities' ,
234- } ,
235- ] )
128+ return uri ? hasPermissionStartsWith ( [ uri ] ) : false
129+ } ) ,
130+ )
236131
237132 function normalizeLabel ( label : string ) : string {
238133 return label . toLowerCase ( ) . normalize ( "NFD" ) . replace ( / [ \u0300 - \u036f ] / g, '' ) . replace ( / / g, '_' ) . replace ( / \. / g, '' )
239134 }
240135
241136 function getMenu ( ) : MenuItem [ ] {
242137 const menuList : MenuItem [ ] = menus . value . reduce ( ( acc : MenuItem [ ] , menu ) => {
243- const label = normalizeLabel ( menu . label )
244- const stateValue = identityStateStore . getStateValue ( label )
138+ const stateKey = typeof menu . name === 'string' && menu . name . trim ( ) ? menu . name : normalizeLabel ( menu . label )
139+ const stateValue = identityStateStore . getStateValue ( stateKey )
245140 const value = stateValue > MaxMenuBadgeCount ? MaxMenuBadgeCount + '+' : stateValue ?. toString ( ) || '0'
246141
247142 if ( menu . _acl && ! hasPermission ( menu . _acl , AccessControlAction . READ , AccessControlPossession . ANY ) ) {
@@ -272,7 +167,7 @@ function useMenu(identityStateStore: ReturnType<typeof useIdentityStateStore>):
272167 const filters = { }
273168 for ( const menu of menus . value ) {
274169 if ( menu . path && menu . badge ) {
275- const label = normalizeLabel ( menu . label )
170+ const stateKey = typeof menu . name === 'string' && menu . name . trim ( ) ? menu . name : normalizeLabel ( menu . label )
276171 const params = new URL ( window . location . origin + encodePath ( menu . path ) ) . searchParams
277172 const queryString = qs . parse ( params . toString ( ) )
278173 const qsFilters = { }
@@ -281,7 +176,7 @@ function useMenu(identityStateStore: ReturnType<typeof useIdentityStateStore>):
281176 qsFilters [ decodeURIComponent ( key ) ] = decodeURIComponent ( value as string )
282177 }
283178
284- filters [ label ] = qsFilters
179+ filters [ stateKey ] = qsFilters
285180 }
286181 }
287182
0 commit comments