feat(xo6/users): Add Users Table (#10029)

This commit is contained in:
Marie Garde
2026-07-24 10:34:04 +02:00
committed by GitHub
parent 954c0aaa9e
commit 316122a683
22 changed files with 598 additions and 12 deletions

View File

@@ -0,0 +1,29 @@
<template>
<UiTableCell>
<div class="user-name">
<UiUserLogo size="small" />
<UiLink :href size="medium">
{{ content }}
</UiLink>
</div>
</UiTableCell>
</template>
<script setup lang="ts">
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiTableCell from '@core/components/ui/table-cell/UiTableCell.vue'
import UiUserLogo from '@core/components/ui/user-logo/UiUserLogo.vue'
defineProps<{
content: string
href?: string
}>()
</script>
<style scoped lang="postcss">
.user-name {
display: flex;
align-items: center;
gap: 0.8rem;
}
</style>

View File

@@ -3,7 +3,7 @@
<div class="ui-head-bar">
<div class="label-wrapper">
<slot name="icon">
<VtsIcon :name="icon" size="medium" />
<VtsIcon :name="icon" size="large" />
</slot>
<h4 v-tooltip class="typo-h4 label text-ellipsis">
<slot />

View File

@@ -156,6 +156,7 @@
"alarm-type:sr_io_throughput_total_per_host": "SR IO throughput total per host exceeds @:n-percent",
"alarm-type:unknown": "Unknown alarm type",
"alarms": "Alarms",
"alert-user-roles": "A user can be assigned roles directly or through groups. Roles inherited from a group cannot be removed at the user level. To revoke them, update the group's roles or remove the user from the group. You can always add individual roles on top of inherited ones.",
"all": "All",
"all-done!": "All done!",
"all-good!": "All good!",
@@ -355,6 +356,7 @@
"duplicating-vm": "Duplicating VM",
"duplication-method": "Duplication method",
"duration": "Duration",
"email": "Email",
"empty": "Empty",
"enabled": "Enabled",
"end-date": "End date",
@@ -581,6 +583,7 @@
"language": "Language",
"language-preferences": "Language preferences",
"last": "Last",
"last-login": "Last login",
"last-n-backup-archives": "Last backup archive | Last {n} backup archives",
"last-n-backup-runs": "Last backup run | Last {n} backup runs",
"last-n-runs": "Last run | Last {n} runs",
@@ -731,6 +734,7 @@
"no-custom-field-detected": "No custom fields detected",
"no-data": "No data",
"no-data-to-calculate": "No data to calculate",
"no-group-attached": "No group attached",
"no-host-attached": "No hosts attached",
"no-host-detected": "No hosts detected",
"no-job-vm": "This VM isnt part of any running job.",
@@ -740,6 +744,7 @@
"no-pools-detected": "No pools detected",
"no-replicated-vm": "This VM isnt being replicated. Configure a replication job to enable it.",
"no-result": "No results",
"no-role-applied": "No role applied",
"no-schedule-available": "No schedules available",
"no-selected-vm-can-be-exported": "No selected VM can be exported",
"no-selected-vm-can-be-migrated": "No selected VM can be migrated",
@@ -748,6 +753,7 @@
"no-storage-repository-detected": "No storage repositories detected",
"no-task": "No tasks",
"no-task-detected": "No tasks detected",
"no-user-detected": "No users detected",
"no-vdi-attached": "No VDIs attached",
"no-vdi-detected": "No VDIs detected",
"no-vif-detected": "No VIFs detected",
@@ -853,6 +859,7 @@
"protocol": "Protocol",
"protocol:port": "@:{'protocol'}:@:{'port'}",
"provider-solutions": "{provider} solutions",
"providers": "Providers",
"provisioning": "Provisioning",
"proxy": "Proxy",
"proxy-url": "Proxy URL",
@@ -920,6 +927,7 @@
"rest-api": "REST API",
"retry": "Retry",
"roles": "Roles",
"roles-applied": "Roles applied",
"root-by-default": "\"root\" by default.",
"rule-type": "Rule type",
"run": "Run",
@@ -1109,6 +1117,7 @@
"used-for-backup": "Used for backup",
"used-space": "Used space",
"used-space-on-sr": "Used space on SR",
"user": "User",
"user-config": "User config",
"user-management": "User management",
"username": "Username",
@@ -1213,6 +1222,7 @@
"xoa-deploy-retry": "Try again to deploy XOA",
"xoa-deploy-successful!": "XOA deployment successful!",
"xoa-ip": "XOA IP address",
"xoa-manager": "XOA manager",
"xoa-password-confirm-different": "XOA password confirmation is different",
"xoa-ssh-account": "XOA SSH account",
"xoa-ssh-password-confirm-different": "SSH password confirmation is different",

View File

@@ -156,6 +156,7 @@
"alarm-type:sr_io_throughput_total_per_host": "Le débit total de l'IO SR par hôte dépasse @:n-percent",
"alarm-type:unknown": "Type d'alarme inconnu",
"alarms": "Alarmes",
"alert-user-roles": "Des rôles peuvent être attribués à un utilisateur directement ou via des groupes. Les rôles hérités dun groupe ne peuvent pas être supprimés au niveau de lutilisateur. Pour les révoquer, mettez à jour les rôles du groupe ou retirez lutilisateur du groupe. Vous pouvez toujours ajouter des rôles individuels aux rôles hérités.",
"all": "Tout",
"all-done!": "Tout est terminé !",
"all-good!": "Tout va bien !",
@@ -355,6 +356,7 @@
"duplicating-vm": "Duplication de la VM",
"duplication-method": "Méthode de duplication",
"duration": "Durée",
"email": "Email",
"empty": "Vide",
"enabled": "Activé",
"end-date": "Date de fin",
@@ -581,6 +583,7 @@
"language": "Langue",
"language-preferences": "Choix de la langue",
"last": "Dernier",
"last-login": "Dernière connexion",
"last-n-backup-archives": "Dernier run d'archivage | {n} derniers archivages",
"last-n-backup-runs": "Dernier run de sauvegarde | {n} dernières sauvegardes",
"last-n-runs": "Dernier run | {n} derniers runs",
@@ -731,6 +734,7 @@
"no-custom-field-detected": "Aucun champ personnalisé détecté",
"no-data": "Aucune donnée",
"no-data-to-calculate": "Aucune donnée à calculer",
"no-group-attached": "Pas de groupe attaché",
"no-host-attached": "Aucun hôte attaché",
"no-host-detected": "Aucun hôte détecté",
"no-job-vm": "Cette VM ne fait partie d'aucun job actif.",
@@ -740,6 +744,7 @@
"no-pools-detected": "Aucun pool détecté",
"no-replicated-vm": "Cette VM n'est pas répliquée. Configurez une tâche de réplication pour l'activer.",
"no-result": "Aucun résultat",
"no-role-applied": "Pas de rôle appliqué",
"no-schedule-available": "Aucune planification disponible",
"no-selected-vm-can-be-exported": "Aucune VM sélectionnée ne peut être exportée",
"no-selected-vm-can-be-migrated": "Aucune VM sélectionnée ne peut être migrée",
@@ -748,6 +753,7 @@
"no-storage-repository-detected": "Aucun dépôt de stockage détecté",
"no-task": "Aucune tâche",
"no-task-detected": "Aucune tâche détectée",
"no-user-detected": "Aucun utilisateur détecté",
"no-vdi-attached": "Aucun VDI attaché",
"no-vdi-detected": "Aucun VDI détecté",
"no-vif-detected": "Aucun VIF détecté",
@@ -853,6 +859,7 @@
"protocol": "Protocole",
"protocol:port": "@:{'protocol'}:@:{'port'}",
"provider-solutions": "Solutions {provider}",
"providers": "Fournisseurs",
"provisioning": "Provisionnement",
"proxy": "Proxy",
"proxy-url": "URL du Proxy",
@@ -920,6 +927,7 @@
"rest-api": "API REST",
"retry": "Réessayer",
"roles": "Rôles",
"roles-applied": "Rôles appliqués",
"root-by-default": "\"root\" par défaut.",
"rule-type": "Type de règle",
"run": "Run",
@@ -1109,6 +1117,7 @@
"used-for-backup": "Utilisé pour la sauvegarde",
"used-space": "Espace utilisé",
"used-space-on-sr": "Espace utilisé sur le SR",
"user": "Utilisateur",
"user-config": "Configuration utilisateur",
"user-management": "Gestion des utilisateurs",
"username": "Nom d'utilisateur",
@@ -1213,6 +1222,7 @@
"xoa-deploy-retry": "Ré-essayer de déployer une XOA",
"xoa-deploy-successful!": "XOA deployée avec succès !",
"xoa-ip": "Adresse IP XOA",
"xoa-manager": "Manager XOA",
"xoa-password-confirm-different": "La confirmation du mot de passe XOA est différente",
"xoa-ssh-account": "Compte SSH de la XOA",
"xoa-ssh-password-confirm-different": "La confirmation du mot de passe SSH est différente",

View File

@@ -0,0 +1,10 @@
import VtsUserNameCell from '@core/components/table/cells/VtsUserNameCell.vue'
import { defineColumn } from '@core/packages/table/define-column.ts'
import { renderHeadCell } from '@core/tables/helpers/render-head-cell.ts'
import type { HeaderConfig } from '@core/tables/types.ts'
import { h } from 'vue'
export const useUserNameColumn = defineColumn((config?: HeaderConfig) => ({
renderHead: () => renderHeadCell(config?.headerLabel),
renderBody: (content: string, props?: { href?: string }) => h(VtsUserNameCell, { content, href: props?.href }),
}))

View File

@@ -0,0 +1,16 @@
import { defineColumns } from '@core/packages/table/define-columns.ts'
import { useSelectItemColumn } from '@core/tables/column-definitions/select-item-column.ts'
import { useTruncatedTextColumn } from '@core/tables/column-definitions/truncated-text-column.ts'
import { useUserNameColumn } from '@core/tables/column-definitions/user-name-column.ts'
import { useI18n } from 'vue-i18n'
export const useUserColumns = defineColumns(() => {
const { t } = useI18n()
return {
username: useUserNameColumn({ headerLabel: () => t('username') }),
email: useTruncatedTextColumn({ headerLabel: () => t('email') }),
providers: useTruncatedTextColumn({ headerLabel: () => t('providers') }),
selectItem: useSelectItemColumn(),
}
})

View File

@@ -67,7 +67,7 @@
<script lang="ts" setup>
import AccountMenu from '@/modules/account/components/menu/AccountMenu.vue'
import AdministrationMenu from '@/modules/navigation/components/AdministrationMenu.vue'
import AdministrationMenu from '@/modules/admin/components/AdministrationMenu.vue'
import { useXoSiteTree } from '@/modules/site/composables/xo-site-tree.composable.ts'
import QuickTaskButton from '@/modules/task/components/QuickTaskButton.vue'
import ThirdParties from '@/modules/third-parties/components/ThirdParties.vue'
@@ -84,14 +84,16 @@ import UiLogoText from '@core/components/ui/logo-text/UiLogoText.vue'
import CoreLayout from '@core/layouts/CoreLayout.vue'
import { useSseStore } from '@core/packages/remote-resource/sse.store.ts'
import { useUiStore } from '@core/stores/ui.store'
import { watchImmediate } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, useTemplateRef, ref, watch } from 'vue'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
defineSlots<{
default(): any
}>()
const { t } = useI18n()
const uiStore = useUiStore()
@@ -102,17 +104,24 @@ const { isConnected } = storeToRefs(sseStore)
const SIDEBAR_PANEL = {
TREEVIEW: 'treeview',
ADMINISTRATION: 'administration',
ADMINISTRATION: 'admin',
} as const
type SidebarPanel = (typeof SIDEBAR_PANEL)[keyof typeof SIDEBAR_PANEL]
const activeSidebarPanel = ref<SidebarPanel>(SIDEBAR_PANEL.TREEVIEW)
const { treeItems, treeItemIndexById, isReady, filter, isSearching, expandToNode } = useXoSiteTree()
const route = useRoute<'/pool/[id]' | '/host/[id]' | '/vm/[id]'>()
const activeSidebarPanel = ref<SidebarPanel>(SIDEBAR_PANEL.TREEVIEW)
watchImmediate(
() => route.path,
path => {
activeSidebarPanel.value = path.startsWith('/admin') ? SIDEBAR_PANEL.ADMINISTRATION : SIDEBAR_PANEL.TREEVIEW
}
)
const siteTreeList = useTemplateRef('siteTreeList')
const { buildXo5Route } = useXoRoutes()

View File

@@ -47,6 +47,12 @@ async function init() {
app.use(router)
app.use(RegleVuePlugin, formValidationConfig)
try {
await router.isReady()
} catch (error) {
console.error('Initial navigation failed', error)
}
app.mount('#app')
}

View File

@@ -4,7 +4,7 @@
<div class="section-title typo-h6">{{ t(section.titleKey) }}</div>
<ul class="links">
<MenuItem v-for="item in section.items" :key="item.labelKey">
<UiLink size="small" class="link" :href="item.href">
<UiLink size="small" class="link" :href="item.href" :to="item.to">
{{ t(item.labelKey) }}
</UiLink>
</MenuItem>
@@ -19,12 +19,14 @@ import MenuItem from '@core/components/menu/MenuItem.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'
type AdminMenuSection = {
titleKey: string
items: {
labelKey: string
href: string | undefined
to: RouteLocationRaw | undefined
}[]
}
@@ -35,10 +37,14 @@ const sections = computed<AdminMenuSection[]>(() => [
{
titleKey: 'user-management',
items: [
{ labelKey: 'users', href: buildXo5Route('/settings/users') },
{ labelKey: 'groups', href: buildXo5Route('/settings/groups') },
{ labelKey: 'roles', href: buildXo5Route('/settings/acls') },
{ labelKey: 'ldap-auth-providers', href: buildXo5Route('/settings/plugins?s=name%3A%2F%5Eauth-%2F') },
{ labelKey: 'users', href: undefined, to: { name: '/admin/user-management' } },
{ labelKey: 'groups', href: buildXo5Route('/settings/groups'), to: undefined },
{ labelKey: 'roles', href: buildXo5Route('/settings/acls'), to: undefined },
{
labelKey: 'ldap-auth-providers',
href: buildXo5Route('/settings/plugins?s=name%3A%2F%5Eauth-%2F'),
to: undefined,
},
],
},
])

View File

@@ -0,0 +1,21 @@
<template>
<UiHeadBar icon="object:organization">
{{ t('user-management') }}
</UiHeadBar>
<TabList>
<RouterLink v-slot="{ isExactActive, href }" :to="{ name: '/admin/user-management/users' }" custom>
<TabItem :active="isExactActive" :href tag="a">
{{ t('users') }}
</TabItem>
</RouterLink>
</TabList>
</template>
<script lang="ts" setup>
import TabItem from '@xen-orchestra/web-core/components/tab/TabItem.vue'
import TabList from '@xen-orchestra/web-core/components/tab/TabList.vue'
import UiHeadBar from '@xen-orchestra/web-core/components/ui/head-bar/UiHeadBar.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,20 @@
import { useXoCollectionState } from '@/shared/composables/xo-collection-state/use-xo-collection-state.ts'
import { BASE_URL } from '@/shared/utils/fetch.util.ts'
import { defineRemoteResource } from '@core/packages/remote-resource/define-remote-resource.ts'
import type { XoGroup } from '@vates/types'
export type FrontXoGroup = Pick<XoGroup, (typeof groupFields)[number]>
const groupFields = ['id', 'name'] as const satisfies readonly (keyof XoGroup)[]
export const useXoGroupCollection = defineRemoteResource({
url: `${BASE_URL}/groups?fields=${groupFields.join(',')}`,
initialData: () => [] as FrontXoGroup[],
state: (groups, context) => {
return useXoCollectionState(groups, {
context,
baseName: 'group',
})
},
})

View File

@@ -0,0 +1,86 @@
<template>
<UiTitle>
{{ t('users') }}
<template #action>
<slot name="title-actions" />
</template>
</UiTitle>
<UiQuerySearchBar @search="(value: string) => (searchQuery = value)" />
<VtsTable :state :pagination-bindings sticky="right">
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="user of paginatedUsers" :key="user.id" :selected="selectedUserId === user.id">
<BodyCells :item="user" />
</VtsRow>
</tbody>
</VtsTable>
</template>
<script setup lang="ts">
import type { FrontXoUser } from '@/modules/user/remote-resources/use-xo-user-collection.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import UiQuerySearchBar from '@core/components/ui/query-search-bar/UiQuerySearchBar.vue'
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
import { useTableState } from '@core/composables/table-state.composable.ts'
import VtsRow from '@xen-orchestra/web-core/components/table/VtsRow.vue'
import VtsTable from '@xen-orchestra/web-core/components/table/VtsTable.vue'
import UiTitle from '@xen-orchestra/web-core/components/ui/title/UiTitle.vue'
import { usePagination } from '@xen-orchestra/web-core/composables/pagination.composable.ts'
import { useUserColumns } from '@xen-orchestra/web-core/tables/column-sets/user-columns.ts'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { users } = defineProps<{
users: FrontXoUser[]
}>()
defineSlots<{
'title-actions'(): any
}>()
const { t } = useI18n()
const { buildXo5Route } = useXoRoutes()
const searchQuery = ref('')
const filteredUsers = computed(() => {
const searchTerm = searchQuery.value.trim().toLocaleLowerCase()
if (!searchTerm) {
return users
}
return users.filter(user => Object.values(user).some(value => String(value).toLocaleLowerCase().includes(searchTerm)))
})
const selectedUserId = useRouteQuery('id')
const xo5UsersHref = computed(() => buildXo5Route('/settings/users'))
const { pageRecords: paginatedUsers, paginationBindings } = usePagination('users', filteredUsers)
const state = useTableState({
empty: () =>
users.length === 0 ? t('no-user-detected') : filteredUsers.value.length === 0 ? { type: 'no-result' } : false,
})
const { HeadCells, BodyCells } = useUserColumns({
body: (user: FrontXoUser) => {
return {
username: r => r(user.name ?? '', { href: xo5UsersHref.value }),
email: r => r(user.email),
providers: r => {
const providers = Object.keys(user.authProviders ?? {})
return r(providers.length === 0 ? t('local') : providers.join(', '))
},
selectItem: r => r(() => (selectedUserId.value = user.id)),
}
},
})
</script>

View File

@@ -0,0 +1,25 @@
<template>
<VtsSidePanel :has-selection="!!user" @close="emit('close')">
<template v-if="user" #default>
<UserInfosCard :user />
<UserGroupsCard :user />
<UserRolesCard />
</template>
</VtsSidePanel>
</template>
<script setup lang="ts">
import UserGroupsCard from '@/modules/user/components/list/panel/cards/UserGroupsCard.vue'
import UserInfosCard from '@/modules/user/components/list/panel/cards/UserInfosCard.vue'
import UserRolesCard from '@/modules/user/components/list/panel/cards/UserRolesCard.vue'
import type { FrontXoUser } from '@/modules/user/remote-resources/use-xo-user-collection.ts'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
defineProps<{
user?: FrontXoUser
}>()
const emit = defineEmits<{
close: []
}>()
</script>

View File

@@ -0,0 +1,71 @@
<template>
<UiCard class="card-container">
<UiCardTitle>
<div class="title">
{{ t('groups') }}
<UiCounter :value="userGroups.length" accent="neutral" size="small" variant="primary" />
</div>
</UiCardTitle>
<div v-if="userGroups.length > 0">
<template v-for="group in userGroups" :key="group.id">
<div class="group-list">
<VtsIcon size="medium" name="table:group" />
<UiLink :href="xo5GroupsHref" size="small">
{{ group.name }}
</UiLink>
</div>
</template>
</div>
<VtsStateHero v-else type="no-data" format="card" horizontal size="extra-small">
{{ t('no-group-attached') }}
</VtsStateHero>
</UiCard>
</template>
<script lang="ts" setup>
import { useXoGroupCollection } from '@/modules/group/remote-ressources/use-xo-group-collection.ts'
import type { FrontXoUser } from '@/modules/user/remote-resources/use-xo-user-collection.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { user } = defineProps<{
user: FrontXoUser
}>()
const { useGetGroupsByIds } = useXoGroupCollection()
const { buildXo5Route } = useXoRoutes()
const xo5GroupsHref = computed(() => buildXo5Route('/settings/groups'))
const userGroups = useGetGroupsByIds(() => user.groups)
const { t } = useI18n()
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
.title {
display: flex;
align-items: center;
gap: 0.8rem;
}
.group-list {
display: flex;
gap: 0.6rem;
margin-block: 0.6rem;
}
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<UiCard class="card-container">
<UiCardTitle>
<div class="title">
<UiUserLogo size="small" />
<UiLink :href="xo5UsersHref" size="medium">
{{ user.name }}
</UiLink>
</div>
</UiCardTitle>
<div class="content">
<VtsCodeSnippet :content="user.id" copy />
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('xoa-manager') }}</template>
<template #value>{{ userPermission }}</template>
<template #addons>
<VtsCopyButton :value="userPermission" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('last-login') }}</template>
<template #value>{{ lastLoginTimestamp }}</template>
<template v-if="lastLoginTimestamp" #addons>
<VtsCopyButton :value="lastLoginTimestamp" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('email') }}</template>
<template #value>{{ user.email }}</template>
<template #addons>
<VtsCopyButton :value="user.email" />
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('providers') }}</template>
<template #value>{{ providers }}</template>
</VtsCardRowKeyValue>
</div>
</UiCard>
</template>
<script lang="ts" setup>
import { useXoUserAuthenticationTokensCollection } from '@/modules/user/remote-resources/use-xo-user-authentication-tokens-collection.ts'
import type { FrontXoUser } from '@/modules/user/remote-resources/use-xo-user-collection.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCodeSnippet from '@core/components/code-snippet/VtsCodeSnippet.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiUserLogo from '@core/components/ui/user-logo/UiUserLogo.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { user } = defineProps<{
user: FrontXoUser
}>()
const { t, d } = useI18n()
const { buildXo5Route } = useXoRoutes()
const xo5UsersHref = computed(() => buildXo5Route('/settings/users'))
const { userAuthenticationTokens } = useXoUserAuthenticationTokensCollection({}, () => user.id)
const lastLoginTimestamp = computed(() => {
let mostRecentTimestamp: number | undefined
for (const token of userAuthenticationTokens.value ?? []) {
for (const usage of Object.values(token.last_uses ?? {})) {
if (mostRecentTimestamp === undefined || usage.timestamp > mostRecentTimestamp) {
mostRecentTimestamp = usage.timestamp
}
}
}
if (mostRecentTimestamp === undefined) {
return undefined
}
return d(mostRecentTimestamp, { dateStyle: 'short', timeStyle: 'medium' })
})
const providers = computed(() => {
const providerList = Object.keys(user.authProviders ?? {})
return providerList.length === 0 ? t('local') : providerList.join(', ')
})
const userPermission = computed(() => {
if (user.permission === 'none') {
return t('user')
}
return user.permission
})
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
.title {
display: flex;
align-items: center;
gap: 0.8rem;
}
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<UiCard class="card-container">
<UiCardTitle>
<div class="title">
{{ t('roles-applied') }}
<UiCounter value="0" accent="neutral" size="small" variant="primary" />
</div>
</UiCardTitle>
<VtsStateHero type="no-data" format="card" horizontal size="extra-small">
{{ t('no-role-applied') }}
</VtsStateHero>
</UiCard>
</template>
<script lang="ts" setup>
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped lang="postcss">
.card-container {
gap: 1.6rem;
.title {
display: flex;
align-items: center;
gap: 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,14 @@
import type { FrontXoUser } from '@/modules/user/remote-resources/use-xo-user-collection.ts'
import { BASE_URL } from '@/shared/utils/fetch.util.ts'
import { defineRemoteResource } from '@core/packages/remote-resource/define-remote-resource.ts'
import type { XoAuthenticationToken } from '@vates/types'
export const useXoUserAuthenticationTokensCollection = defineRemoteResource({
url: (userId: FrontXoUser['id']) => `${BASE_URL}/users/${userId}/authentication_tokens`,
initialData: () => [] as XoAuthenticationToken[],
state: (userAuthenticationTokens, context) => ({
userAuthenticationTokens,
areUserAuthenticationTokensReady: context.isReady,
areUserAuthenticationTokensFetchError: context.hasError,
}),
})

View File

@@ -5,7 +5,14 @@ import type { XoUser } from '@vates/types'
export type FrontXoUser = Pick<XoUser, (typeof userFields)[number]>
const userFields = ['id', 'name', 'permission', 'email', 'groups'] as const satisfies readonly (keyof XoUser)[]
const userFields = [
'id',
'name',
'permission',
'email',
'groups',
'authProviders',
] as const satisfies readonly (keyof XoUser)[]
export const useXoUserCollection = defineRemoteResource({
url: `${BASE_URL}/users?fields=${userFields.join(',')}`,

View File

@@ -0,0 +1,16 @@
<template>
<RouterView v-slot="{ Component }">
<UserManagementHeader />
<component :is="Component" :users />
</RouterView>
</template>
<script lang="ts" setup>
import UserManagementHeader from '@/modules/admin/components/UserManagementHeader.vue'
import { useXoUserCollection } from '@/modules/user/remote-resources/use-xo-user-collection.ts'
import { useDefaultTab } from '@core/composables/default-tab.composable.ts'
useDefaultTab('/admin/user-management', 'users')
const { users } = useXoUserCollection()
</script>

View File

@@ -0,0 +1,51 @@
<template>
<VtsContentSidePanel>
<div class="container">
<UiAlert v-if="!alertDismissed" class="alert" accent="info" close @close="alertDismissed = true">
{{ t('alert-user-roles') }}
</UiAlert>
<UiCard>
<UsersTable :users />
</UiCard>
</div>
<UserSidePanel :user="selectedUser" @close="selectedUser = undefined" />
</VtsContentSidePanel>
</template>
<script setup lang="ts">
import UserSidePanel from '@/modules/user/components/list/panel/UserSidePanel.vue'
import UsersTable from '@/modules/user/components/list/UsersTable.vue'
import { type FrontXoUser, useXoUserCollection } from '@/modules/user/remote-resources/use-xo-user-collection.ts'
import VtsContentSidePanel from '@core/components/layout/VtsContentSidePanel.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
users: FrontXoUser[]
}>()
const { t } = useI18n()
const { getUserById } = useXoUserCollection()
const alertDismissed = ref(false)
const selectedUser = useRouteQuery<FrontXoUser | undefined>('id', {
toData: id => getUserById(id as FrontXoUser['id']),
toQuery: user => user?.id ?? '',
})
</script>
<style scoped lang="postcss">
.container {
height: fit-content;
gap: 0.8rem;
margin: 0.8rem;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -91,6 +91,20 @@ declare module 'vue-router/auto-routes' {
{ path: ParamValue<false> },
| never
>,
'/admin/user-management': RouteRecordInfo<
'/admin/user-management',
'/admin/user-management',
Record<never, never>,
Record<never, never>,
| '/admin/user-management/users'
>,
'/admin/user-management/users': RouteRecordInfo<
'/admin/user-management/users',
'/admin/user-management/users',
Record<never, never>,
Record<never, never>,
| never
>,
'/backup/[id]': RouteRecordInfo<
'/backup/[id]',
'/backup/:id',
@@ -511,6 +525,19 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/admin/user-management.vue': {
routes:
| '/admin/user-management'
| '/admin/user-management/users'
views:
| 'default'
}
'src/pages/admin/user-management/users.vue': {
routes:
| '/admin/user-management/users'
views:
| never
}
'src/pages/backup/[id].vue': {
routes:
| '/backup/[id]'

View File

@@ -45,6 +45,7 @@
- [REST API] Expose `/rest/v0/acl-roles/:id/users` (PR [#10085](https://github.com/vatesfr/xen-orchestra/pull/10085))
- [REST API] Expose `/rest/v0/acl-roles/:id/groups` (PR [#10085](https://github.com/vatesfr/xen-orchestra/pull/10085))
- [XO6] Fix some design inconsistency between pages (PR [#10109](https://github.com/vatesfr/xen-orchestra/pull/10109))
- [XO6/Users] Add Users Table (PR [#10029](https://github.com/vatesfr/xen-orchestra/pull/10029))
### Bug fixes