Skip to content

Commit ff14dfa

Browse files
committed
feat: enhance menu item configuration and debugging options
- Updated the menu item structure to replace `_acl` with `acl` for improved clarity and flexibility in access control. - Introduced role-based visibility for menu items, allowing for more granular control over which users can see specific entries. - Enhanced the drawer component with new debugging features, including context menus for opening items in new tabs and debugging dialogs. - Improved the layout and rendering logic in the drawer and index pages to support the new debugging functionalities.
1 parent 0ac4431 commit ff14dfa

File tree

7 files changed

+267
-74
lines changed

7 files changed

+267
-74
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<template lang="pug">
2+
q-dialog(v-model="dialogOpen" persistent position="top")
3+
q-card(style="min-width: 35vw;")
4+
q-toolbar.bg-orange-8.text-light
5+
q-icon(name="mdi-bug" size="sm" color="light")
6+
q-toolbar-title Debug entrée de menu
7+
q-btn(flat round dense icon="mdi-close" v-close-popup)
8+
q-card-section
9+
q-list(separator)
10+
q-item
11+
q-item-section
12+
q-item-label Label (nom)
13+
.text-weight-medium {{ selectedItem?.label || 'N/A' }} ({{ selectedItem?.name || 'N/A' }})
14+
q-item
15+
q-item-section
16+
q-item-label Part
17+
.text-body2 {{ selectedItem?.part || '-' }}
18+
q-item
19+
q-item-section
20+
q-item-label ACL requis
21+
.text-body2 {{ selectedItem?.acl?.length ? selectedItem?.acl.join(', ') : 'N/A' }}
22+
q-item
23+
q-item-section
24+
q-item-label Rôles requis
25+
.text-body2 {{ selectedItem?.roles?.length ? selectedItem?.roles.join(', ') : 'N/A' }}
26+
27+
q-card-section.q-pa-none
28+
q-bar.bg-orange-8.text-white
29+
q-toolbar-title Détails de l'entrée
30+
31+
client-only
32+
q-separator.q-my-md
33+
div(style="min-height: 320px;")
34+
LazyMonacoEditor.fit(
35+
style="min-height: 320px;"
36+
:model-value='JSON.stringify(selectedItem || {}, null, 2)'
37+
:options='monacoOptions'
38+
lang='json'
39+
)
40+
</template>
41+
42+
<script lang="ts">
43+
export default defineNuxtComponent({
44+
name: 'DebugMenuEntryDialog',
45+
props: {
46+
modelValue: {
47+
type: Boolean,
48+
required: true,
49+
},
50+
selectedItem: {
51+
type: Object as () => Record<string, any> | null,
52+
required: false,
53+
default: null,
54+
},
55+
monacoOptions: {
56+
type: Object as () => Record<string, any>,
57+
required: false,
58+
default: () => ({}),
59+
},
60+
},
61+
emits: ['update:modelValue'],
62+
computed: {
63+
dialogOpen: {
64+
get() {
65+
return this.modelValue
66+
},
67+
set(value: boolean) {
68+
this.$emit('update:modelValue', value)
69+
},
70+
},
71+
},
72+
})
73+
</script>
74+

apps/web/src/components/layouts/default/drawer.vue

Lines changed: 81 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,91 @@
11
<template lang="pug">
2-
q-drawer.flex(v-model="drawer" side="left" :mini="true" :breakpoint="0" bordered persistent)
3-
template(#mini)
4-
q-scroll-area.fit.mini-slot.cursor-pointer
5-
q-list
6-
q-item(to="/" clickable v-ripple)
7-
q-item-section(avatar)
8-
q-icon(name="mdi-home")
9-
q-separator
10-
q-list(v-for="(part, i) in visibleMenuParts" :key="part.label")
11-
div(v-for="menu in part.menus" :key="menu.path")
12-
q-item(
13-
clickable
14-
v-ripple
15-
:to="encodePath(menu.path)" :active="encodePath(menu.path) === $route.fullPath"
16-
active-class="q-item--active"
17-
)
18-
q-separator(v-if='encodePath(menu.path) === $route.fullPath' vertical color='primary' size="5px" style='position: absolute; left: 0; height: 100%; margin-top: -8px;')
2+
div
3+
q-drawer.flex(v-model="drawer" side="left" :mini="true" :breakpoint="0" bordered persistent)
4+
template(#mini)
5+
q-scroll-area.fit.mini-slot.cursor-pointer
6+
q-list
7+
q-item(to="/" clickable v-ripple)
198
q-item-section(avatar)
20-
q-icon(
21-
:style='getButtonStyle(menu)'
22-
:name="menu.icon"
23-
:color="menu.color"
24-
:class="{ 'gradient-icon': isGradient(menu) }"
25-
)
26-
q-badge(
27-
v-if="menu.badge"
28-
:style='getBadgeStyle(menu)'
29-
:color="menu.badge.color"
30-
:class='["text-" + menu.badge.textColor || "text-white"]'
31-
v-text='menu.badge.value'
32-
floating
9+
q-icon(name="mdi-home")
10+
q-separator
11+
q-list(v-for="(part, i) in visibleMenuParts" :key="part.label")
12+
div(v-for="menu in part.menus" :key="menu.path")
13+
q-item(
14+
clickable
15+
v-ripple
16+
:to="encodePath(menu.path)" :active="encodePath(menu.path) === $route.fullPath"
17+
active-class="q-item--active"
3318
)
34-
q-tooltip.shadow-5.text-uppercase.text-body2(
35-
v-if="drawer !== false"
36-
:class='["bg-" + menu.color || "bg-primary", "text-" + menu.textColor || "text-primary"]'
37-
anchor="center right"
38-
self="center left"
39-
) {{ menu.label }}
40-
q-separator(v-if="i < visibleMenuParts.length - 1")
19+
q-separator(v-if='encodePath(menu.path) === $route.fullPath' vertical color='primary' size="5px" style='position: absolute; left: 0; height: 100%; margin-top: -8px;')
20+
q-item-section(avatar)
21+
q-icon(
22+
:style='getButtonStyle(menu)'
23+
:name="menu.icon"
24+
:color="menu.color"
25+
:class="{ 'gradient-icon': isGradient(menu) }"
26+
)
27+
q-badge(
28+
v-if="menu.badge"
29+
:style='getBadgeStyle(menu)'
30+
:color="menu.badge.color"
31+
:class='["text-" + menu.badge.textColor || "text-white"]'
32+
v-text='menu.badge.value'
33+
floating
34+
)
35+
q-tooltip.shadow-5.text-uppercase.text-body2(
36+
v-if="drawer !== false"
37+
:class='["bg-" + menu.color || "bg-primary", "text-" + menu.textColor || "text-primary"]'
38+
anchor="center right"
39+
self="center left"
40+
) {{ menu.label }}
41+
q-popup-proxy(context-menu :offset="[0, 10]")
42+
q-list(dense)
43+
q-item(clickable v-ripple @click.stop.prevent="openMenu(menu)")
44+
q-item-section(avatar)
45+
q-icon(name="mdi-open-in-app" color="primary")
46+
q-item-section Ouvrir
47+
q-item(clickable v-ripple @click.stop.prevent="openMenuNewTab(menu)")
48+
q-item-section(avatar)
49+
q-icon(name="mdi-open-in-new" color="primary")
50+
q-item-section Ouvrir dans un nouvel onglet
51+
q-separator(v-if="debug")
52+
q-item(
53+
clickable
54+
v-if="debug"
55+
v-ripple
56+
@click.stop.prevent="openDebugDialog(menu)"
57+
)
58+
q-item-section(avatar)
59+
q-icon(name="mdi-bug" color="warning")
60+
q-item-section
61+
q-item-label Debug
62+
q-separator(v-if="i < visibleMenuParts.length - 1")
63+
sesame-core-debug-menu-entry-dialog(
64+
v-model="debugDialogOpen"
65+
:selected-item="debugSelectedItem"
66+
:monaco-options="monacoOptions"
67+
)
4168
</template>
4269

4370
<script lang="ts">
4471
import { useIdentityStateStore } from '~/stores/identityState'
72+
import { useDebug } from '~/composables/useDebug'
4573
4674
export default defineNuxtComponent({
4775
name: 'LayoutsDefaultDrawer',
4876
data() {
4977
return {
5078
drawer: true,
5179
menuParts: [],
80+
debugDialogOpen: false,
81+
debugSelectedItem: null as any,
5282
}
5383
},
5484
async setup() {
5585
const identityStateStore = useIdentityStateStore()
5686
const { menuParts, getMenuByPart, initialize } = useMenu(identityStateStore)
5787
const { encodePath } = useFiltersQuery(ref([]))
88+
const { debug, monacoOptions } = useDebug()
5889
const visibleMenuParts = computed(() => menuParts.value
5990
.map(part => ({
6091
...part,
@@ -69,9 +100,22 @@ export default defineNuxtComponent({
69100
visibleMenuParts,
70101
getMenuByPart,
71102
encodePath,
103+
debug,
104+
monacoOptions,
72105
}
73106
},
74107
methods: {
108+
openMenu(item: any) {
109+
this.$router.push(item.path)
110+
},
111+
openMenuNewTab(item: any) {
112+
const encoded = this.encodePath(item.path)
113+
window.open(encoded, '_blank', 'noopener,noreferrer')
114+
},
115+
openDebugDialog(item: any) {
116+
this.debugSelectedItem = item
117+
this.debugDialogOpen = true
118+
},
75119
isGradient(item) {
76120
const c = item.drawerColor || item.color || ''
77121
return typeof c === 'string' && c.startsWith('linear-gradient')

apps/web/src/composables/useMenu.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import qs from 'qs'
44
import type { useIdentityStateStore } from '~/stores/identityState'
55
import { getDefaultMenuEntries } from '~/constants/defaultMenuEntries'
66
import { sort } from 'radash'
7+
import { AC_ADMIN_ROLE } from './useAccessControl'
78

89
const config = useAppConfig() as any
910

@@ -24,6 +25,18 @@ function useMenu(identityStateStore: ReturnType<typeof useIdentityStateStore>):
2425
const useDefaultEntries = config?.menus?.useDefaultEntries !== false
2526
const menuEntries = (config?.menus?.entries as any[]) || []
2627
const { hasPermission, hasPermissionStartsWith } = useAccessControl()
28+
const $auth = useAuth()
29+
const userRoles = ($auth.user?.roles as string[]) || []
30+
const isAdmin = userRoles.includes(AC_ADMIN_ROLE)
31+
32+
function getRequiredAcls(entry: any): string[] {
33+
const directAcls = Array.isArray(entry?.acl) ? entry.acl : []
34+
if (directAcls.length > 0) return directAcls
35+
36+
// Compatibilité : ancien champ `_acl` (string) -> `acl: [string]`
37+
const legacyAcl = typeof entry?._acl === 'string' ? entry._acl : ''
38+
return legacyAcl ? [legacyAcl] : []
39+
}
2740

2841
function normalizeNameFromLabel(label: string): string {
2942
// Auto-remplissage côté déploiement :
@@ -93,10 +106,20 @@ function useMenu(identityStateStore: ReturnType<typeof useIdentityStateStore>):
93106
const matchIndex = matchIndexByPath >= 0 ? matchIndexByPath : matchIndexByName >= 0 ? matchIndexByName : matchIndexByLabel
94107

95108
if (matchIndex >= 0) {
96-
mergedMenuEntries[matchIndex] = {
97-
...mergedMenuEntries[matchIndex],
109+
const existingEntry = mergedMenuEntries[matchIndex] as any
110+
const nextEntry: any = {
111+
...existingEntry,
98112
...(overrideEntry as any),
99113
}
114+
115+
// Règle de fusion : `acl` fourni dans menus.yml -> on ajoute, on n'écrase pas.
116+
if (Array.isArray((overrideEntry as any)?.acl)) {
117+
const baseAcls = Array.isArray(existingEntry?.acl) ? existingEntry.acl : []
118+
const overrideAcls = (overrideEntry as any).acl as string[]
119+
nextEntry.acl = Array.from(new Set([...baseAcls, ...overrideAcls].filter((a) => typeof a === 'string' && a.trim())))
120+
}
121+
122+
mergedMenuEntries[matchIndex] = nextEntry
100123
} else {
101124
unmatchedOverrides.push(overrideEntry as any)
102125
}
@@ -117,8 +140,13 @@ function useMenu(identityStateStore: ReturnType<typeof useIdentityStateStore>):
117140

118141
const menus = ref<MenuItem[]>(
119142
mergedMenuEntries.filter((entry) => {
120-
// Si `_acl` est fourni, on laisse `getMenu()` gérer l'autorisation exacte.
121-
if (entry._acl) return true
143+
const entryRoles = (entry as any)?.roles as unknown
144+
const requiredRoles = Array.isArray(entryRoles) ? (entryRoles as string[]) : []
145+
const canSeeByRole = isAdmin || requiredRoles.length === 0 ? true : requiredRoles.some((r) => userRoles.includes(r))
146+
if (!canSeeByRole) return false
147+
148+
// Si `acl` est fourni, on laisse `getMenu()` gérer l'autorisation exacte.
149+
if (getRequiredAcls(entry).length > 0) return true
122150
if (!entry.path) return false
123151

124152
const basePath = entry.path.replace(/^\//, '')
@@ -139,8 +167,12 @@ function useMenu(identityStateStore: ReturnType<typeof useIdentityStateStore>):
139167
const stateValue = identityStateStore.getStateValue(stateKey)
140168
const value = stateValue > MaxMenuBadgeCount ? MaxMenuBadgeCount + '+' : stateValue?.toString() || '0'
141169

142-
if (menu._acl && !hasPermission(menu._acl, AccessControlAction.READ, AccessControlPossession.ANY)) {
143-
return acc
170+
const requiredAcls = getRequiredAcls(menu as any)
171+
if (requiredAcls.length > 0) {
172+
const canSeeByAcl = requiredAcls.some((requiredAcl) =>
173+
hasPermission(requiredAcl, AccessControlAction.READ, AccessControlPossession.ANY),
174+
)
175+
if (!canSeeByAcl) return acc
144176
}
145177

146178
acc.push({

0 commit comments

Comments
 (0)