feat(xo6/privilege): add scope sub-page to the privilege detail page (#10655)

* feat(xo6/privilege): add scope sub-page to the privilege detail page

The Scope sub-page shows which objects a privilege applies to: its scope
type, the query of a dynamic selection, and the table of the objects it
currently applies to, whether selected manually, matched by the query or
covering all the objects of its resource.

See XO-2948

* fix(xo6/role): read the privilege scope type from its resource in the privilege scope page

* fix(xo6/role): show the error state of the objects a privilege currently applies to

* feat(xo6/privilege): show the full objects table in the privilege scope page

As in the mockups, the objects a privilege currently applies to are listed
in the same table as in its creation drawer (columns of the resource, search
engine), read-only, instead of a list of names.
This commit is contained in:
Thierry Goettelmann
2026-11-09 08:13:12 +01:00
committed by GitHub
parent 264e0db674
commit c35d56c057
13 changed files with 225 additions and 36 deletions

View File

@@ -37,8 +37,10 @@ function renderBodyCheckbox({ selected, onToggle, disabled }: CheckboxCellBindin
}
export const useCheckboxColumn = defineColumn(() => ({
renderHead: (model: Ref<boolean | undefined>, options?: CheckboxHeadCellOptions) =>
h(VtsHeaderCell, { style: { width: COLUMN_WIDTH } }, () => renderHeadCheckbox(model, options)),
renderHead: (model?: Ref<boolean | undefined>, options?: CheckboxHeadCellOptions) =>
h(VtsHeaderCell, { style: { width: COLUMN_WIDTH } }, () =>
model === undefined ? undefined : renderHeadCheckbox(model, options)
),
renderBody: (bindings?: CheckboxCellBindings) =>
h(UiTableCell, { style: { width: COLUMN_WIDTH } }, () =>
bindings === undefined ? undefined : renderBodyCheckbox(bindings)

View File

@@ -30,7 +30,7 @@
v-if="formData.scope.scopeType === 'manual-selection' && selectableResource !== undefined"
class="selector"
>
<PrivilegeScopeManualSelection
<PrivilegeScopeResourceTable
v-model:selected-ids="formData.scope.selectedIds"
:resource="selectableResource"
/>
@@ -132,9 +132,9 @@
</template>
<script lang="ts" setup>
import PrivilegeScopeManualSelection from '@/modules/role/components/drawer/scope/PrivilegeScopeManualSelection.vue'
import PrivilegeScopeObjectsList from '@/modules/role/components/drawer/scope/PrivilegeScopeObjectsList.vue'
import PrivilegeScopePreviewTable from '@/modules/role/components/drawer/scope/PrivilegeScopePreviewTable.vue'
import PrivilegeScopeResourceTable from '@/modules/role/components/drawer/scope/PrivilegeScopeResourceTable.vue'
import RoleFormSelect from '@/modules/role/components/form/inputs/RoleFormSelect.vue'
import { usePrivilegeScopeTypeLabels } from '@/modules/role/composables/use-privilege-scope-type-labels.composable.ts'
import { useNewPrivilegeForm } from '@/modules/role/form/new-privilege/use-new-privilege-form.ts'

View File

@@ -1,7 +1,7 @@
<template>
<div class="privilege-scope-hosts-table">
<VtsQueryBuilder :model-value="filter" :schema @search="filter = $event" />
<VtsTable :state :pagination-bindings :selection-bindings>
<VtsTable :state :pagination-bindings :selection-bindings="readonly ? undefined : selectionBindings">
<thead>
<tr>
<HeadCells />
@@ -34,12 +34,21 @@ import { logicAnd, logicNot, logicOr } from '@vueuse/math'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const selectedIds = defineModel<string[]>('selectedIds', { required: true })
const { visibleIds, readonly = false } = defineProps<{
visibleIds?: string[]
readonly?: boolean
}>()
const selectedIds = defineModel<string[]>('selectedIds', { default: () => [] })
const { t } = useI18n()
const { hosts, areHostsReady, hasHostFetchError, isMasterHost } = useXoHostCollection()
const visibleHosts = computed(() =>
visibleIds === undefined ? hosts.value : hosts.value.filter(host => visibleIds.includes(host.id))
)
const { pifsByHost, arePifsReady, hasPifFetchError } = useXoPifCollection()
const isReady = logicAnd(areHostsReady, arePifsReady)
@@ -48,7 +57,7 @@ const {
items: filteredHosts,
filter,
schema,
} = useHostQueryBuilder('privilege-scope-hosts', hosts, {
} = useHostQueryBuilder('privilege-scope-hosts', visibleHosts, {
ignoreRoute: true,
})
@@ -56,7 +65,11 @@ const state = useTableState({
busy: logicNot(isReady),
error: logicOr(hasHostFetchError, hasPifFetchError),
empty: () =>
hosts.value.length === 0 ? t('no-host-detected') : filteredHosts.value.length === 0 ? { type: 'no-result' } : false,
visibleHosts.value.length === 0
? t('no-host-detected')
: filteredHosts.value.length === 0
? { type: 'no-result' }
: false,
})
const { pageRecords: paginatedHosts, paginationBindings } = usePagination('privilege-scope-hosts', filteredHosts, {
@@ -65,7 +78,7 @@ const { pageRecords: paginatedHosts, paginationBindings } = usePagination('privi
const { pageSelectionModel, isSelected, toggleSelection, selectionBindings } = usePrivilegeScopeSelection({
selectedIds,
items: hosts,
items: visibleHosts,
filteredItems: filteredHosts,
pageItems: paginatedHosts,
isReady,
@@ -85,7 +98,7 @@ function getMasterIcon(host: FrontXoHost) {
const { HeadCells, BodyCells } = useHostColumns({
exclude: ['srStatus', 'selectItem'],
head: () => ({
checkbox: r => r(pageSelectionModel),
checkbox: r => (readonly ? r() : r(pageSelectionModel)),
}),
body: (host: FrontXoHost) => {
const ipAddresses = computed(() => getHostIpAddresses(host.address, pifsByHost.value.get(host.id)))
@@ -93,7 +106,7 @@ const { HeadCells, BodyCells } = useHostColumns({
const rightIcon = computed(() => getMasterIcon(host))
return {
checkbox: r => r({ selected: isSelected(host.id), onToggle: () => toggleSelection(host.id) }),
checkbox: r => (readonly ? r() : r({ selected: isSelected(host.id), onToggle: () => toggleSelection(host.id) })),
host: r =>
r({
label: host.name_label,

View File

@@ -1,7 +1,7 @@
<template>
<div class="privilege-scope-objects-table">
<VtsQueryBuilder :model-value="filter" :schema @search="filter = $event" />
<VtsTable :state :pagination-bindings :selection-bindings>
<VtsTable :state :pagination-bindings :selection-bindings="readonly ? undefined : selectionBindings">
<thead>
<tr>
<HeadCells />
@@ -30,15 +30,23 @@ import { usePrivilegeScopeObjectColumns } from '@core/tables/column-sets/privile
import { useStringSchema } from '@core/utils/query-builder/use-string-schema.ts'
import { useI18n } from 'vue-i18n'
const { tableId, objects, isReady, hasError, emptyMessage } = defineProps<{
const {
tableId,
objects,
isReady,
hasError,
emptyMessage,
readonly = false,
} = defineProps<{
tableId: string
objects: PrivilegeScopeObject[]
isReady: boolean
hasError: boolean
emptyMessage: string
readonly?: boolean
}>()
const selectedIds = defineModel<string[]>('selectedIds', { required: true })
const selectedIds = defineModel<string[]>('selectedIds', { default: () => [] })
const { t } = useI18n()
@@ -70,10 +78,11 @@ const { pageSelectionModel, isSelected, toggleSelection, selectionBindings } = u
const { HeadCells, BodyCells } = usePrivilegeScopeObjectColumns({
head: () => ({
checkbox: r => r(pageSelectionModel),
checkbox: r => (readonly ? r() : r(pageSelectionModel)),
}),
body: (object: PrivilegeScopeObject) => ({
checkbox: r => r({ selected: isSelected(object.id), onToggle: () => toggleSelection(object.id) }),
checkbox: r =>
readonly ? r() : r({ selected: isSelected(object.id), onToggle: () => toggleSelection(object.id) }),
object: r => r({ label: object.label, to: object.to, icon: object.icon }),
}),
})

View File

@@ -1,16 +1,17 @@
<template>
<PrivilegeScopeVmsTable v-if="resource === 'vm'" v-model:selected-ids="selectedIds" />
<PrivilegeScopeHostsTable v-else-if="resource === 'host'" v-model:selected-ids="selectedIds" />
<PrivilegeScopeSrsTable v-else-if="resource === 'sr'" v-model:selected-ids="selectedIds" />
<PrivilegeScopeVmsTable v-if="resource === 'vm'" v-model:selected-ids="selectedIds" :visible-ids :readonly />
<PrivilegeScopeHostsTable v-else-if="resource === 'host'" v-model:selected-ids="selectedIds" :visible-ids :readonly />
<PrivilegeScopeSrsTable v-else-if="resource === 'sr'" v-model:selected-ids="selectedIds" :visible-ids :readonly />
<PrivilegeScopeObjectsTable
v-else
:key="resource"
v-model:selected-ids="selectedIds"
:table-id="`privilege-scope-${resource}`"
:objects
:objects="visibleObjects"
:is-ready
:has-error
:empty-message
:readonly
/>
</template>
@@ -24,16 +25,26 @@ import type { SelectablePrivilegeResource } from '@/modules/role/utils/privilege
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { resource } = defineProps<{
const {
resource,
visibleIds,
readonly = false,
} = defineProps<{
resource: SelectablePrivilegeResource
visibleIds?: string[]
readonly?: boolean
}>()
const selectedIds = defineModel<string[]>('selectedIds', { required: true })
const selectedIds = defineModel<string[]>('selectedIds', { default: () => [] })
const { t } = useI18n()
const { objects, isReady, hasError } = usePrivilegeScopeObjects(() => resource)
const visibleObjects = computed(() =>
visibleIds === undefined ? objects.value : objects.value.filter(object => visibleIds.includes(object.id))
)
const emptyMessage = computed(() => {
if (resource === 'pool') {
return t('no-pools-detected')

View File

@@ -1,7 +1,7 @@
<template>
<div class="privilege-scope-srs-table">
<VtsQueryBuilder :model-value="filter" :schema @search="filter = $event" />
<VtsTable :state :pagination-bindings :selection-bindings>
<VtsTable :state :pagination-bindings :selection-bindings="readonly ? undefined : selectionBindings">
<thead>
<tr>
<HeadCells />
@@ -37,7 +37,12 @@ import { logicNot } from '@vueuse/math'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const selectedIds = defineModel<string[]>('selectedIds', { required: true })
const { visibleIds, readonly = false } = defineProps<{
visibleIds?: string[]
readonly?: boolean
}>()
const selectedIds = defineModel<string[]>('selectedIds', { default: () => [] })
const SCOPE: SrScope = { type: SR_SCOPE_TYPE.POOL }
@@ -45,11 +50,15 @@ const { t } = useI18n()
const { srs, areSrsReady, hasSrFetchError, isDefaultSr } = useXoSrCollection()
const visibleSrs = computed(() =>
visibleIds === undefined ? srs.value : srs.value.filter(sr => visibleIds.includes(sr.id))
)
const {
items: filteredSrs,
filter,
schema,
} = useSrQueryBuilder('privilege-scope-srs', srs, {
} = useSrQueryBuilder('privilege-scope-srs', visibleSrs, {
ignoreRoute: true,
})
@@ -57,7 +66,7 @@ const state = useTableState({
busy: logicNot(areSrsReady),
error: hasSrFetchError,
empty: () =>
srs.value.length === 0
visibleSrs.value.length === 0
? t('no-storage-repository-detected')
: filteredSrs.value.length === 0
? { type: 'no-result' }
@@ -70,7 +79,7 @@ const { pageRecords: paginatedSrs, paginationBindings } = usePagination('privile
const { pageSelectionModel, isSelected, toggleSelection, selectionBindings } = usePrivilegeScopeSelection({
selectedIds,
items: srs,
items: visibleSrs,
filteredItems: filteredSrs,
pageItems: paginatedSrs,
isReady: areSrsReady,
@@ -90,7 +99,7 @@ function getPrimaryIcon(sr: FrontXoSr) {
const { HeadCells, BodyCells } = useSrColumns({
exclude: ['actions'],
head: () => ({
checkbox: r => r(pageSelectionModel),
checkbox: r => (readonly ? r() : r(pageSelectionModel)),
}),
body: (sr: FrontXoSr) => {
const rightIcon = computed(() => getPrimaryIcon(sr))
@@ -98,7 +107,7 @@ const { HeadCells, BodyCells } = useSrColumns({
const { srStatusIcon, getSrAccessModeLabel } = useXoSrUtils(sr, () => SCOPE)
return {
checkbox: r => r({ selected: isSelected(sr.id), onToggle: () => toggleSelection(sr.id) }),
checkbox: r => (readonly ? r() : r({ selected: isSelected(sr.id), onToggle: () => toggleSelection(sr.id) })),
storageRepository: r =>
r({
label: sr.name_label,

View File

@@ -1,7 +1,7 @@
<template>
<div class="privilege-scope-vms-table">
<VtsQueryBuilder :model-value="filter" :schema @search="filter = $event" />
<VtsTable :state :pagination-bindings :selection-bindings>
<VtsTable :state :pagination-bindings :selection-bindings="readonly ? undefined : selectionBindings">
<thead>
<tr>
<HeadCells />
@@ -31,18 +31,27 @@ import { logicNot } from '@vueuse/math'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const selectedIds = defineModel<string[]>('selectedIds', { required: true })
const { visibleIds, readonly = false } = defineProps<{
visibleIds?: string[]
readonly?: boolean
}>()
const selectedIds = defineModel<string[]>('selectedIds', { default: () => [] })
const { t } = useI18n()
const { vms, areVmsReady, hasVmFetchError } = useXoVmCollection()
const visibleVms = computed(() =>
visibleIds === undefined ? vms.value : vms.value.filter(vm => visibleIds.includes(vm.id))
)
const {
items: filteredVms,
filter,
schema,
getDisplayData,
} = useVmQueryBuilder('privilege-scope-vms', vms, {
} = useVmQueryBuilder('privilege-scope-vms', visibleVms, {
ignoreRoute: true,
})
@@ -50,7 +59,11 @@ const state = useTableState({
busy: logicNot(areVmsReady),
error: hasVmFetchError,
empty: () =>
vms.value.length === 0 ? t('no-vm-detected') : filteredVms.value.length === 0 ? { type: 'no-result' } : false,
visibleVms.value.length === 0
? t('no-vm-detected')
: filteredVms.value.length === 0
? { type: 'no-result' }
: false,
})
const displayVms = computed(() => filteredVms.value.map(vm => getDisplayData(vm)))
@@ -61,7 +74,7 @@ const { pageRecords: paginatedVms, paginationBindings } = usePagination('privile
const { pageSelectionModel, isSelected, toggleSelection, selectionBindings } = usePrivilegeScopeSelection({
selectedIds,
items: vms,
items: visibleVms,
filteredItems: filteredVms,
pageItems: paginatedVms,
isReady: areVmsReady,
@@ -70,10 +83,10 @@ const { pageSelectionModel, isSelected, toggleSelection, selectionBindings } = u
const { HeadCells, BodyCells } = useVmColumns({
exclude: ['actions'],
head: () => ({
checkbox: r => r(pageSelectionModel),
checkbox: r => (readonly ? r() : r(pageSelectionModel)),
}),
body: (vm: VmDisplayData) => ({
checkbox: r => r({ selected: isSelected(vm.id), onToggle: () => toggleSelection(vm.id) }),
checkbox: r => (readonly ? r() : r({ selected: isSelected(vm.id), onToggle: () => toggleSelection(vm.id) })),
vm: r => r({ label: vm.name_label, to: { name: '/vm/[id]/dashboard', params: { id: vm.id } }, icon: vm.vmIcon }),
ipAddresses: r => r(vm.ipAddresses),
vcpus: r => r(vm.CPUs.number),

View File

@@ -11,6 +11,13 @@
>
{{ t('general') }}
</UiMenuItem>
<UiMenuItem
:size
:to="{ name: '/admin/privilege/[id]/scope', params: { id: privilege.id } }"
:selected="route.name === '/admin/privilege/[id]/scope'"
>
{{ t('privilege:scope') }}
</UiMenuItem>
</template>
<slot />
</DetailPageLayout>

View File

@@ -0,0 +1,41 @@
<template>
<UiCard>
<UiTitle>
{{ t('privilege:scope') }}
</UiTitle>
<VtsTabularKeyValueList>
<VtsTabularKeyValueRow :label="t('privilege:scope-type')" :value="scopeTypeLabels[scopeType]" />
<VtsTabularKeyValueRow
v-if="scopeType === 'dynamic-selection' && privilege.selector !== undefined"
:label="t('privilege:selector')"
>
<template #value>
<UiLogEntryViewer :content="privilege.selector" size="medium" accent="info" />
</template>
</VtsTabularKeyValueRow>
</VtsTabularKeyValueList>
</UiCard>
</template>
<script lang="ts" setup>
import { usePrivilegeScopeTypeLabels } from '@/modules/role/composables/use-privilege-scope-type-labels.composable.ts'
import { getPrivilegeScopeType } from '@/modules/role/utils/privilege-scope.util.ts'
import VtsTabularKeyValueList from '@core/components/tabular-key-value-list/VtsTabularKeyValueList.vue'
import VtsTabularKeyValueRow from '@core/components/tabular-key-value-row/VtsTabularKeyValueRow.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiLogEntryViewer from '@core/components/ui/log-entry-viewer/UiLogEntryViewer.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import type { XoAclBasePrivilege } from '@vates/types'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { privilege } = defineProps<{
privilege: XoAclBasePrivilege
}>()
const { t } = useI18n()
const scopeTypeLabels = usePrivilegeScopeTypeLabels()
const scopeType = computed(() => getPrivilegeScopeType(privilege.resource, privilege.selector))
</script>

View File

@@ -0,0 +1,35 @@
<template>
<UiCard>
<UiTitle>
{{ t('privilege:currently-applied-to') }}
</UiTitle>
<VtsStateHero v-if="hasAppliedToIdsError" type="error" format="card" size="small">
{{ t('error-no-data') }}
</VtsStateHero>
<VtsStateHero v-else-if="appliedToIds === undefined" type="busy" format="card" size="small" />
<VtsStateHero v-else-if="appliedToIds.length === 0" type="no-data" format="card" size="small">
{{ t('privilege:no-object') }}
</VtsStateHero>
<PrivilegeScopeResourceTable v-else :resource :visible-ids="appliedToIds" readonly />
</UiCard>
</template>
<script lang="ts" setup>
import PrivilegeScopeResourceTable from '@/modules/role/components/drawer/scope/PrivilegeScopeResourceTable.vue'
import { usePrivilegeAppliedToIds } from '@/modules/role/composables/use-privilege-applied-to-ids.composable.ts'
import type { SelectablePrivilegeResource } from '@/modules/role/utils/privilege-catalogue.util.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import type { XoAclBasePrivilege } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { privilege } = defineProps<{
privilege: XoAclBasePrivilege
resource: SelectablePrivilegeResource
}>()
const { t } = useI18n()
const { appliedToIds, hasAppliedToIdsError } = usePrivilegeAppliedToIds(() => privilege)
</script>

View File

@@ -0,0 +1,33 @@
<template>
<PrivilegeDetailLayout :privilege>
<div class="cards">
<PrivilegeScopeDetailsCard :privilege />
<PrivilegeScopeObjectsCard v-if="selectableResource !== undefined" :privilege :resource="selectableResource" />
</div>
</PrivilegeDetailLayout>
</template>
<script lang="ts" setup>
import PrivilegeDetailLayout from '@/modules/role/components/privilege/detail/PrivilegeDetailLayout.vue'
import PrivilegeScopeDetailsCard from '@/modules/role/components/privilege/detail/PrivilegeScopeDetailsCard.vue'
import PrivilegeScopeObjectsCard from '@/modules/role/components/privilege/detail/PrivilegeScopeObjectsCard.vue'
import { isSelectablePrivilegeResource } from '@/modules/role/utils/privilege-catalogue.util.ts'
import type { XoAclBasePrivilege } from '@vates/types'
import { computed } from 'vue'
const { privilege } = defineProps<{
privilege: XoAclBasePrivilege
}>()
const selectableResource = computed(() =>
isSelectablePrivilegeResource(privilege.resource) ? privilege.resource : undefined
)
</script>
<style lang="postcss" scoped>
.cards {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
</style>

View File

@@ -177,6 +177,7 @@ declare module 'vue-router/auto-routes' {
{ id: ParamValue<true> },
{ id: ParamValue<false> },
| '/admin/privilege/[id]/general'
| '/admin/privilege/[id]/scope'
>,
'/admin/privilege/[id]/general': RouteRecordInfo<
'/admin/privilege/[id]/general',
@@ -185,6 +186,13 @@ declare module 'vue-router/auto-routes' {
{ id: ParamValue<false> },
| never
>,
'/admin/privilege/[id]/scope': RouteRecordInfo<
'/admin/privilege/[id]/scope',
'/admin/privilege/:id/scope',
{ id: ParamValue<true> },
{ id: ParamValue<false> },
| never
>,
'/admin/role/[id]': RouteRecordInfo<
'/admin/role/[id]',
'/admin/role/:id',
@@ -805,6 +813,7 @@ declare module 'vue-router/auto-routes' {
routes:
| '/admin/privilege/[id]'
| '/admin/privilege/[id]/general'
| '/admin/privilege/[id]/scope'
views:
| 'default'
}
@@ -814,6 +823,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/admin/privilege/[id]/scope.vue': {
routes:
| '/admin/privilege/[id]/scope'
views:
| never
}
'src/pages/admin/role/[id].vue': {
routes:
| '/admin/role/[id]'

View File

@@ -30,6 +30,7 @@
- [XO6/Roles] List the groups a role is assigned to in its detail page, assign the role to groups and detach it from them
- [XO6/Roles] List the privileges of a role in its detail page, and see the objects each privilege currently applies to in a side panel
- [XO6/Privileges] Open a privilege's detail page from the privileges table of a role or its side panel, to see its details
- [XO6/Privileges] See the scope of a privilege in its detail page: its scope type, its query and the objects it currently applies to
### Bug fixes