mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
feat(xo6/user): add user creation from the users list (#10658)
* feat(xo6/user): add user creation from the users list A "New" button in the users list opens a multi-step drawer: user details, groups, roles, then a review where each step can be edited again. Roles inherited from the selected groups are shown but not assigned directly: only the additional roles become direct roles of the user. The user is created first, then added to its groups and assigned its roles. A username or email address that is already taken shows a dedicated error, and the drawer stays open so the form can be fixed. See XO-2358 * fix(xo6/user): add the new user to its groups one at a time to keep every membership * refactor(xo6/user): reuse the user details locale key in the user creation * refactor(xo6/user): share the groups and roles selection tables between the drawers The roles header checkbox is now disabled on a page of inherited roles only.
This commit is contained in:
committed by
GitHub
parent
25b837ef1a
commit
5ad0691574
@@ -480,6 +480,7 @@
|
||||
"form:error:maximum": "Must be {max} or less",
|
||||
"form:error:min-items": "Must contain at least {n} item | Must contain at least {n} items",
|
||||
"form:error:minimum": "Must be {min} or more",
|
||||
"form:error:password-mismatch": "Passwords do not match",
|
||||
"form:error:required": "This field is required",
|
||||
"form:n-errors-summary": "{n} field needs attention | {n} fields need attention",
|
||||
"form:warning:out-of-range": "Should be between {min} and {max}",
|
||||
@@ -969,6 +970,8 @@
|
||||
"new-network:select-interface": "Select an interface",
|
||||
"new-traffic-rule:add": "Add new traffic rule",
|
||||
"new-traffic-rule:error-message": "An error occurred while creating the traffic rule. Please review your configuration settings and try again.",
|
||||
"new-user:error-already-exists": "A user with this username or email address already exists.",
|
||||
"new-user:error-message": "An error occurred while creating the user. Please review your settings and try again.",
|
||||
"new-vdi:accepted-formats": "Accepted formats: {formats}",
|
||||
"new-vdi:add": "Add new VDI",
|
||||
"new-vdi:error-message": "An error occurred while creating the VDI. Please review your configuration settings (name, storage repository, allocated space) and try again.",
|
||||
@@ -1098,6 +1101,7 @@
|
||||
"pagination:all": "@:all",
|
||||
"partially-connected": "Partially connected",
|
||||
"password": "Password",
|
||||
"password-confirmation": "Password confirmation",
|
||||
"password-invalid": "Password invalid",
|
||||
"password-required": "Password is required.",
|
||||
"patch-name": "Patch name",
|
||||
@@ -1514,9 +1518,13 @@
|
||||
"user-config": "User config",
|
||||
"user-details": "User details",
|
||||
"user-management": "User management",
|
||||
"user:create-new": "Create new user",
|
||||
"user:delete-warning": "Deleting this user will permanently remove their access and associated permissions. This action cannot be undone. Proceed with caution. | Deleting these users will permanently remove their access and associated permissions. This action cannot be undone. Proceed with caution.",
|
||||
"user:direct-roles": "Direct roles",
|
||||
"user:groups-assignment": "Groups assignment",
|
||||
"user:roles-assignment": "Roles assignment",
|
||||
"user:roles-from-group": "From {group} group",
|
||||
"user:roles-from-groups-info": "Roles assigned through assigned groups are automatically applied to this user. You can add additional roles below if needed.",
|
||||
"username": "Username",
|
||||
"username-required": "Username is required.",
|
||||
"users": "Users",
|
||||
|
||||
@@ -480,6 +480,7 @@
|
||||
"form:error:maximum": "Doit être inférieur ou égal à {max}",
|
||||
"form:error:min-items": "Doit contenir au moins {n} élément | Doit contenir au moins {n} éléments",
|
||||
"form:error:minimum": "Doit être supérieur ou égal à {min}",
|
||||
"form:error:password-mismatch": "Les mots de passe ne correspondent pas",
|
||||
"form:error:required": "Ce champ est requis",
|
||||
"form:n-errors-summary": "{n} champ est à corriger | {n} champs sont à corriger",
|
||||
"form:warning:out-of-range": "Devrait être compris entre {min} et {max}",
|
||||
@@ -969,6 +970,8 @@
|
||||
"new-network:select-interface": "Sélectionner une interface",
|
||||
"new-traffic-rule:add": "Ajouter une nouvelle règle de trafic",
|
||||
"new-traffic-rule:error-message": "Une erreur s'est produite lors de la création de la règle de trafic. Veuillez vérifier vos paramètres de configuration et réessayer.",
|
||||
"new-user:error-already-exists": "Un utilisateur avec ce nom d'utilisateur ou cette adresse e-mail existe déjà.",
|
||||
"new-user:error-message": "Une erreur s'est produite lors de la création de l'utilisateur. Veuillez vérifier vos paramètres et réessayer.",
|
||||
"new-vdi:accepted-formats": "Formats acceptés : {formats}",
|
||||
"new-vdi:add": "Ajouter un nouveau VDI",
|
||||
"new-vdi:error-message": "Une erreur s'est produite lors de la création du VDI. Veuillez vérifier vos paramètres (nom, dépôt de stockage, espace alloué) et réessayer.",
|
||||
@@ -1098,6 +1101,7 @@
|
||||
"pagination:all": "Tous",
|
||||
"partially-connected": "Partiellement connecté",
|
||||
"password": "Mot de passe",
|
||||
"password-confirmation": "Confirmation du mot de passe",
|
||||
"password-invalid": "Mot de passe incorrect",
|
||||
"password-required": "Le mot de passe est requis.",
|
||||
"patch-name": "Nom du patch",
|
||||
@@ -1514,9 +1518,13 @@
|
||||
"user-config": "Configuration utilisateur",
|
||||
"user-details": "Détails de l'utilisateur",
|
||||
"user-management": "Gestion des utilisateurs",
|
||||
"user:create-new": "Créer un nouvel utilisateur",
|
||||
"user:delete-warning": "Supprimer cet utilisateur retirera définitivement son accès et les permissions associées. Cette action est irréversible. Procédez avec prudence. | Supprimer ces utilisateurs retirera définitivement leurs accès et les permissions associées. Cette action est irréversible. Procédez avec prudence.",
|
||||
"user:direct-roles": "Rôles directs",
|
||||
"user:groups-assignment": "Affectation des groupes",
|
||||
"user:roles-assignment": "Affectation des rôles",
|
||||
"user:roles-from-group": "Du groupe {group}",
|
||||
"user:roles-from-groups-info": "Les rôles des groupes affectés sont automatiquement appliqués à cet utilisateur. Vous pouvez ajouter des rôles supplémentaires ci-dessous si besoin.",
|
||||
"username": "Nom d'utilisateur",
|
||||
"username-required": "Le nom d'utilisateur est requis.",
|
||||
"users": "Utilisateurs",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="groups-selection-table">
|
||||
<VtsQueryBuilder :model-value="filter" :schema @search="filter = $event" />
|
||||
<VtsTable :state :pagination-bindings :selection-bindings>
|
||||
<thead>
|
||||
<tr>
|
||||
<HeadCells />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<VtsRow v-for="group of paginatedGroups" :key="group.id">
|
||||
<BodyCells :item="group" />
|
||||
</VtsRow>
|
||||
</tbody>
|
||||
</VtsTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
type FilterableGroup,
|
||||
useGroupQueryBuilder,
|
||||
} from '@/modules/group/composables/use-group-query-builder.composable.ts'
|
||||
import type { FrontXoGroup } from '@/modules/group/remote-resources/use-xo-group-collection.ts'
|
||||
import VtsQueryBuilder from '@core/components/query-builder/VtsQueryBuilder.vue'
|
||||
import VtsRow from '@core/components/table/VtsRow.vue'
|
||||
import VtsTable from '@core/components/table/VtsTable.vue'
|
||||
import { usePagination } from '@core/composables/pagination.composable.ts'
|
||||
import { useTableSelection } from '@core/composables/table-selection.composable.ts'
|
||||
import { useTableState } from '@core/composables/table-state.composable.ts'
|
||||
import { useGroupColumns } from '@core/tables/column-sets/group-columns.ts'
|
||||
import { watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { groups, busy, error } = defineProps<{
|
||||
groups: FrontXoGroup[]
|
||||
busy?: boolean
|
||||
error?: boolean
|
||||
}>()
|
||||
|
||||
const selectedGroupIds = defineModel<FrontXoGroup['id'][]>('selectedGroupIds', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
items: filteredGroups,
|
||||
filter,
|
||||
schema,
|
||||
} = useGroupQueryBuilder('groups-selection', () => groups, {
|
||||
ignoreRoute: true,
|
||||
})
|
||||
|
||||
const { pageRecords: paginatedGroups, paginationBindings } = usePagination('groups-selection', filteredGroups, {
|
||||
ignoreRoute: true,
|
||||
})
|
||||
|
||||
const state = useTableState({
|
||||
busy: () => busy,
|
||||
error: () => error,
|
||||
empty: () =>
|
||||
groups.length === 0 ? t('no-group-detected') : filteredGroups.value.length === 0 ? { type: 'no-result' } : false,
|
||||
})
|
||||
|
||||
const { selectedIds, pageSelectionModel, isSelected, toggleSelection, selectionBindings } = useTableSelection({
|
||||
items: () => groups,
|
||||
filteredItems: filteredGroups,
|
||||
pageItems: paginatedGroups,
|
||||
getItemId: group => group.id,
|
||||
})
|
||||
|
||||
watch(selectedIds, ids => {
|
||||
selectedGroupIds.value = groups.filter(group => ids.includes(group.id)).map(group => group.id)
|
||||
})
|
||||
|
||||
const { HeadCells, BodyCells } = useGroupColumns({
|
||||
exclude: ['selectItem', 'actions'],
|
||||
head: () => ({
|
||||
checkbox: r => r(pageSelectionModel),
|
||||
}),
|
||||
body: (group: FilterableGroup) => ({
|
||||
checkbox: r => r({ selected: isSelected(group.id), onToggle: () => toggleSelection(group.id) }),
|
||||
name: r => r({ label: group.name, icon: 'table:group' }),
|
||||
provider: r => r(group.provider),
|
||||
users: r => r(group.usersCount),
|
||||
roles: r => r(group.rolesCount),
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.groups-selection-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -6,19 +6,7 @@
|
||||
|
||||
<template #content>
|
||||
<div class="content">
|
||||
<VtsQueryBuilder :model-value="filter" :schema @search="filter = $event" />
|
||||
<VtsTable :state :pagination-bindings :selection-bindings>
|
||||
<thead>
|
||||
<tr>
|
||||
<HeadCells />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<VtsRow v-for="group of paginatedGroups" :key="group.id">
|
||||
<BodyCells :item="group" />
|
||||
</VtsRow>
|
||||
</tbody>
|
||||
</VtsTable>
|
||||
<GroupsSelectionTable v-model:selected-group-ids="selectedGroupIds" :groups />
|
||||
<UiInfo v-if="isSelectionErrorVisible" accent="danger">
|
||||
{{ t('select-at-least-one-group.') }}
|
||||
</UiInfo>
|
||||
@@ -35,22 +23,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
type FilterableGroup,
|
||||
useGroupQueryBuilder,
|
||||
} from '@/modules/group/composables/use-group-query-builder.composable.ts'
|
||||
import GroupsSelectionTable from '@/modules/group/components/drawer/GroupsSelectionTable.vue'
|
||||
import type { FrontXoGroup } from '@/modules/group/remote-resources/use-xo-group-collection.ts'
|
||||
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
|
||||
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
|
||||
import VtsQueryBuilder from '@core/components/query-builder/VtsQueryBuilder.vue'
|
||||
import VtsRow from '@core/components/table/VtsRow.vue'
|
||||
import VtsTable from '@core/components/table/VtsTable.vue'
|
||||
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
|
||||
import UiInfo from '@core/components/ui/info/UiInfo.vue'
|
||||
import { usePagination } from '@core/composables/pagination.composable.ts'
|
||||
import { useTableSelection } from '@core/composables/table-selection.composable.ts'
|
||||
import { useTableState } from '@core/composables/table-state.composable.ts'
|
||||
import { useGroupColumns } from '@core/tables/column-sets/group-columns.ts'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -66,60 +44,24 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
items: filteredGroups,
|
||||
filter,
|
||||
schema,
|
||||
} = useGroupQueryBuilder('select-groups', () => groups, {
|
||||
ignoreRoute: true,
|
||||
})
|
||||
|
||||
const { pageRecords: paginatedGroups, paginationBindings } = usePagination('select-groups', filteredGroups, {
|
||||
ignoreRoute: true,
|
||||
})
|
||||
|
||||
const state = useTableState({
|
||||
empty: () =>
|
||||
groups.length === 0 ? t('no-group-detected') : filteredGroups.value.length === 0 ? { type: 'no-result' } : false,
|
||||
})
|
||||
|
||||
const { selectedIds, pageSelectionModel, isSelected, toggleSelection, selectionBindings } = useTableSelection({
|
||||
items: () => groups,
|
||||
filteredItems: filteredGroups,
|
||||
pageItems: paginatedGroups,
|
||||
getItemId: group => group.id,
|
||||
})
|
||||
const selectedGroupIds = ref<FrontXoGroup['id'][]>([])
|
||||
|
||||
const hasTriedToConfirm = ref(false)
|
||||
|
||||
const isSelectionErrorVisible = computed(() => hasTriedToConfirm.value && selectedIds.value.length === 0)
|
||||
const isSelectionErrorVisible = computed(() => hasTriedToConfirm.value && selectedGroupIds.value.length === 0)
|
||||
|
||||
function confirm() {
|
||||
hasTriedToConfirm.value = true
|
||||
|
||||
if (selectedIds.value.length === 0) {
|
||||
if (selectedGroupIds.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
emit(
|
||||
'confirm',
|
||||
groups.filter(group => selectedIds.value.includes(group.id))
|
||||
groups.filter(group => selectedGroupIds.value.includes(group.id))
|
||||
)
|
||||
}
|
||||
|
||||
const { HeadCells, BodyCells } = useGroupColumns({
|
||||
exclude: ['selectItem', 'actions'],
|
||||
head: () => ({
|
||||
checkbox: r => r(pageSelectionModel),
|
||||
}),
|
||||
body: (group: FilterableGroup) => ({
|
||||
checkbox: r => r({ selected: isSelected(group.id), onToggle: () => toggleSelection(group.id) }),
|
||||
name: r => r({ label: group.name, icon: 'table:group' }),
|
||||
provider: r => r(group.provider),
|
||||
users: r => r(group.usersCount),
|
||||
roles: r => r(group.rolesCount),
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="roles-selection-table">
|
||||
<VtsQueryBuilder :model-value="filter" :schema @search="filter = $event" />
|
||||
<VtsTable :state :pagination-bindings :selection-bindings>
|
||||
<thead>
|
||||
<tr>
|
||||
<HeadCells />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<VtsRow v-for="role of paginatedRoles" :key="role.id">
|
||||
<BodyCells :item="role" />
|
||||
</VtsRow>
|
||||
</tbody>
|
||||
</VtsTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
type FilterableRole,
|
||||
useRoleQueryBuilder,
|
||||
} from '@/modules/role/composables/use-role-query-builder.composable.ts'
|
||||
import type { FrontXoRole } from '@/modules/role/remote-resources/use-xo-role-collection.ts'
|
||||
import VtsQueryBuilder from '@core/components/query-builder/VtsQueryBuilder.vue'
|
||||
import VtsRow from '@core/components/table/VtsRow.vue'
|
||||
import VtsTable from '@core/components/table/VtsTable.vue'
|
||||
import { usePagination } from '@core/composables/pagination.composable.ts'
|
||||
import { useTableSelection } from '@core/composables/table-selection.composable.ts'
|
||||
import { useTableState } from '@core/composables/table-state.composable.ts'
|
||||
import { useRoleColumns } from '@core/tables/column-sets/role-columns.ts'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
roles,
|
||||
inheritedRoleIds = [],
|
||||
busy,
|
||||
error,
|
||||
} = defineProps<{
|
||||
roles: FrontXoRole[]
|
||||
inheritedRoleIds?: FrontXoRole['id'][]
|
||||
busy?: boolean
|
||||
error?: boolean
|
||||
}>()
|
||||
|
||||
const selectedRoleIds = defineModel<FrontXoRole['id'][]>('selectedRoleIds', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
items: filteredRoles,
|
||||
filter,
|
||||
schema,
|
||||
} = useRoleQueryBuilder('roles-selection', () => roles, {
|
||||
ignoreRoute: true,
|
||||
})
|
||||
|
||||
const { pageRecords: paginatedRoles, paginationBindings } = usePagination('roles-selection', filteredRoles, {
|
||||
ignoreRoute: true,
|
||||
})
|
||||
|
||||
const state = useTableState({
|
||||
busy: () => busy,
|
||||
error: () => error,
|
||||
empty: () =>
|
||||
roles.length === 0 ? t('no-role-detected') : filteredRoles.value.length === 0 ? { type: 'no-result' } : false,
|
||||
})
|
||||
|
||||
const selectableRoles = computed(() => roles.filter(role => !inheritedRoleIds.includes(role.id)))
|
||||
|
||||
const { selectedIds, pageSelectionModel, isSelected, toggleSelection, selectionBindings } = useTableSelection({
|
||||
items: selectableRoles,
|
||||
filteredItems: filteredRoles,
|
||||
pageItems: paginatedRoles,
|
||||
getItemId: role => role.id,
|
||||
})
|
||||
|
||||
const hasNoSelectableRoleOnPage = computed(() => paginatedRoles.value.every(role => inheritedRoleIds.includes(role.id)))
|
||||
|
||||
watch(selectedIds, ids => {
|
||||
selectedRoleIds.value = selectableRoles.value.filter(role => ids.includes(role.id)).map(role => role.id)
|
||||
})
|
||||
|
||||
const { HeadCells, BodyCells } = useRoleColumns({
|
||||
exclude: ['group', 'actions'],
|
||||
head: () => ({
|
||||
checkbox: r => r(pageSelectionModel, { disabled: hasNoSelectableRoleOnPage.value }),
|
||||
}),
|
||||
body: (role: FilterableRole) => ({
|
||||
checkbox: r =>
|
||||
r({
|
||||
selected: inheritedRoleIds.includes(role.id) || isSelected(role.id),
|
||||
disabled: inheritedRoleIds.includes(role.id),
|
||||
onToggle: () => toggleSelection(role.id),
|
||||
}),
|
||||
name: r => r({ label: role.name, icon: 'fa:key' }),
|
||||
description: r => r(role.description),
|
||||
users: r => r(role.usersCount),
|
||||
groups: r => r(role.groupsCount),
|
||||
privileges: r => r(role.privilegesCount),
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.roles-selection-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -6,19 +6,7 @@
|
||||
|
||||
<template #content>
|
||||
<div class="content">
|
||||
<VtsQueryBuilder :model-value="filter" :schema @search="filter = $event" />
|
||||
<VtsTable :state :pagination-bindings :selection-bindings>
|
||||
<thead>
|
||||
<tr>
|
||||
<HeadCells />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<VtsRow v-for="role of paginatedRoles" :key="role.id">
|
||||
<BodyCells :item="role" />
|
||||
</VtsRow>
|
||||
</tbody>
|
||||
</VtsTable>
|
||||
<RolesSelectionTable v-model:selected-role-ids="selectedRoleIds" :roles />
|
||||
<UiInfo v-if="isSelectionErrorVisible" accent="danger">
|
||||
{{ t('select-at-least-one-role.') }}
|
||||
</UiInfo>
|
||||
@@ -35,22 +23,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
type FilterableRole,
|
||||
useRoleQueryBuilder,
|
||||
} from '@/modules/role/composables/use-role-query-builder.composable.ts'
|
||||
import RolesSelectionTable from '@/modules/role/components/drawer/RolesSelectionTable.vue'
|
||||
import type { FrontXoRole } from '@/modules/role/remote-resources/use-xo-role-collection.ts'
|
||||
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
|
||||
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
|
||||
import VtsQueryBuilder from '@core/components/query-builder/VtsQueryBuilder.vue'
|
||||
import VtsRow from '@core/components/table/VtsRow.vue'
|
||||
import VtsTable from '@core/components/table/VtsTable.vue'
|
||||
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
|
||||
import UiInfo from '@core/components/ui/info/UiInfo.vue'
|
||||
import { usePagination } from '@core/composables/pagination.composable.ts'
|
||||
import { useTableSelection } from '@core/composables/table-selection.composable.ts'
|
||||
import { useTableState } from '@core/composables/table-state.composable.ts'
|
||||
import { useRoleColumns } from '@core/tables/column-sets/role-columns.ts'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -66,61 +44,24 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
items: filteredRoles,
|
||||
filter,
|
||||
schema,
|
||||
} = useRoleQueryBuilder('select-roles', () => roles, {
|
||||
ignoreRoute: true,
|
||||
})
|
||||
|
||||
const { pageRecords: paginatedRoles, paginationBindings } = usePagination('select-roles', filteredRoles, {
|
||||
ignoreRoute: true,
|
||||
})
|
||||
|
||||
const state = useTableState({
|
||||
empty: () =>
|
||||
roles.length === 0 ? t('no-role-detected') : filteredRoles.value.length === 0 ? { type: 'no-result' } : false,
|
||||
})
|
||||
|
||||
const { selectedIds, pageSelectionModel, isSelected, toggleSelection, selectionBindings } = useTableSelection({
|
||||
items: () => roles,
|
||||
filteredItems: filteredRoles,
|
||||
pageItems: paginatedRoles,
|
||||
getItemId: role => role.id,
|
||||
})
|
||||
const selectedRoleIds = ref<FrontXoRole['id'][]>([])
|
||||
|
||||
const hasTriedToConfirm = ref(false)
|
||||
|
||||
const isSelectionErrorVisible = computed(() => hasTriedToConfirm.value && selectedIds.value.length === 0)
|
||||
const isSelectionErrorVisible = computed(() => hasTriedToConfirm.value && selectedRoleIds.value.length === 0)
|
||||
|
||||
function confirm() {
|
||||
hasTriedToConfirm.value = true
|
||||
|
||||
if (selectedIds.value.length === 0) {
|
||||
if (selectedRoleIds.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
emit(
|
||||
'confirm',
|
||||
roles.filter(role => selectedIds.value.includes(role.id))
|
||||
roles.filter(role => selectedRoleIds.value.includes(role.id))
|
||||
)
|
||||
}
|
||||
|
||||
const { HeadCells, BodyCells } = useRoleColumns({
|
||||
exclude: ['group', 'actions'],
|
||||
head: () => ({
|
||||
checkbox: r => r(pageSelectionModel),
|
||||
}),
|
||||
body: (role: FilterableRole) => ({
|
||||
checkbox: r => r({ selected: isSelected(role.id), onToggle: () => toggleSelection(role.id) }),
|
||||
name: r => r({ label: role.name, icon: 'fa:key' }),
|
||||
description: r => r(role.description),
|
||||
users: r => r(role.usersCount),
|
||||
groups: r => r(role.groupsCount),
|
||||
privileges: r => r(role.privilegesCount),
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<UiDrawer @confirm="onSubmit()" @dismiss="emit('cancel')">
|
||||
<template #title>
|
||||
{{ t('user:create-new') }}
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<UiAlert v-if="errorMessage !== undefined" accent="danger" class="error">
|
||||
{{ errorMessage }}
|
||||
</UiAlert>
|
||||
|
||||
<UiStepper :steps :current-step="currentStepIndex">
|
||||
<div v-if="currentStep === 'details'" class="details">
|
||||
<UserFormTextInput v-bind="usernameInputBindings" class="full-row" />
|
||||
<UserFormTextInput v-bind="firstnameInputBindings" />
|
||||
<UserFormTextInput v-bind="lastnameInputBindings" />
|
||||
<UserFormTextInput v-bind="emailInputBindings" class="full-row" />
|
||||
<UserFormTextInput v-bind="passwordInputBindings" />
|
||||
<UserFormTextInput v-bind="passwordConfirmationInputBindings" />
|
||||
</div>
|
||||
|
||||
<GroupsSelectionTable
|
||||
v-show="currentStep === 'groups'"
|
||||
v-model:selected-group-ids="formData.groups.groupIds"
|
||||
:groups="selectableGroups"
|
||||
:busy="!areGroupsReady"
|
||||
:error="hasGroupFetchError"
|
||||
/>
|
||||
|
||||
<div v-show="currentStep === 'roles'" class="roles">
|
||||
<UiAlert accent="info">
|
||||
{{ t('user:roles-from-groups-info') }}
|
||||
</UiAlert>
|
||||
<RolesSelectionTable
|
||||
v-model:selected-role-ids="formData.roles.roleIds"
|
||||
:roles
|
||||
:inherited-role-ids
|
||||
:busy="!areRolesReady || !areGroupsReady"
|
||||
:error="hasRoleFetchError || hasGroupFetchError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewUserReview
|
||||
v-if="currentStep === 'review'"
|
||||
:details="formData.details"
|
||||
:groups="selectedGroups"
|
||||
:direct-role-ids
|
||||
@edit="step => backTo(step)"
|
||||
/>
|
||||
</UiStepper>
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<VtsOverlayCancelButton v-if="currentStep === 'details'" @click="emit('cancel')" />
|
||||
<VtsOverlayButton v-else variant="secondary" @click="back()">
|
||||
{{ t('action:go-back') }}
|
||||
</VtsOverlayButton>
|
||||
<VtsOverlayConfirmButton>
|
||||
{{ currentStep === 'review' ? t('action:create') : t('action:continue') }}
|
||||
</VtsOverlayConfirmButton>
|
||||
</template>
|
||||
</UiDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import GroupsSelectionTable from '@/modules/group/components/drawer/GroupsSelectionTable.vue'
|
||||
import { useXoGroupCollection } from '@/modules/group/remote-resources/use-xo-group-collection.ts'
|
||||
import RolesSelectionTable from '@/modules/role/components/drawer/RolesSelectionTable.vue'
|
||||
import { useXoRoleCollection } from '@/modules/role/remote-resources/use-xo-role-collection.ts'
|
||||
import NewUserReview from '@/modules/user/components/drawer/NewUserReview.vue'
|
||||
import UserFormTextInput from '@/modules/user/components/form/inputs/UserFormTextInput.vue'
|
||||
import { useNewUserForm } from '@/modules/user/form/new/use-new-user-form.ts'
|
||||
import type { NewUserPayload } from '@/modules/user/types.ts'
|
||||
import type { StepDefinition } from '@core/components/ui/stepper/UiStepper.vue'
|
||||
import VtsOverlayButton from '@core/components/overlay/VtsOverlayButton.vue'
|
||||
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
|
||||
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
|
||||
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
|
||||
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
|
||||
import UiStepper from '@core/components/ui/stepper/UiStepper.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { errorMessage } = defineProps<{
|
||||
errorMessage?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [payload: NewUserPayload]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
formData,
|
||||
selectedGroups,
|
||||
inheritedRoleIds,
|
||||
directRoleIds,
|
||||
currentStep,
|
||||
next,
|
||||
back,
|
||||
backTo,
|
||||
usernameInputBindings,
|
||||
firstnameInputBindings,
|
||||
lastnameInputBindings,
|
||||
emailInputBindings,
|
||||
passwordInputBindings,
|
||||
passwordConfirmationInputBindings,
|
||||
validateAndBuildPayload,
|
||||
} = useNewUserForm()
|
||||
|
||||
const { groups, areGroupsReady, hasGroupFetchError } = useXoGroupCollection()
|
||||
|
||||
const { roles, areRolesReady, hasRoleFetchError } = useXoRoleCollection()
|
||||
|
||||
const selectableGroups = computed(() => groups.value.filter(group => group.provider === undefined))
|
||||
|
||||
const steps = computed<StepDefinition[]>(() => [
|
||||
{ label: t('user-details') },
|
||||
{ label: t('user:groups-assignment') },
|
||||
{ label: t('user:roles-assignment') },
|
||||
{ label: t('review-and-confirm') },
|
||||
])
|
||||
|
||||
const stepNames = ['details', 'groups', 'roles', 'review'] as const
|
||||
|
||||
const currentStepIndex = computed(() => stepNames.indexOf(currentStep.value))
|
||||
|
||||
async function onSubmit() {
|
||||
if (currentStep.value !== 'review') {
|
||||
await next()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await validateAndBuildPayload()
|
||||
|
||||
if (payload !== undefined) {
|
||||
emit('confirm', payload)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.error {
|
||||
margin-block-end: 2.4rem;
|
||||
}
|
||||
|
||||
.roles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.4rem;
|
||||
|
||||
@media (--medium-or-large) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-items: start;
|
||||
|
||||
.full-row {
|
||||
grid-column: 1 / -1;
|
||||
max-width: calc(50% - 1.2rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="new-user-review">
|
||||
<UiTitle>
|
||||
{{ t('user-details') }}
|
||||
<template #action>
|
||||
<UiButton left-icon="fa:edit" variant="tertiary" accent="brand" size="medium" @click="emit('edit', 'details')">
|
||||
{{ t('action:edit') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiTitle>
|
||||
|
||||
<dl class="fields">
|
||||
<dt>{{ t('username') }}</dt>
|
||||
<dd>{{ details.username }}</dd>
|
||||
|
||||
<dt>{{ t('first-name') }}</dt>
|
||||
<dd>{{ details.firstname }}</dd>
|
||||
|
||||
<dt>{{ t('last-name') }}</dt>
|
||||
<dd>{{ details.lastname }}</dd>
|
||||
|
||||
<dt>{{ t('email-address') }}</dt>
|
||||
<dd>{{ details.email }}</dd>
|
||||
</dl>
|
||||
|
||||
<UiTitle class="section-title">
|
||||
{{ t('user:groups-assignment') }}
|
||||
<template #action>
|
||||
<UiButton left-icon="fa:edit" variant="tertiary" accent="brand" size="medium" @click="emit('edit', 'groups')">
|
||||
{{ t('action:edit') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiTitle>
|
||||
|
||||
<UiLinkList v-if="groups.length > 0" variant="vertical">
|
||||
<UiLink
|
||||
v-for="group of groups"
|
||||
:key="group.id"
|
||||
icon="table:group"
|
||||
size="small"
|
||||
:to="{ name: '/admin/user-management/groups', query: { id: group.id } }"
|
||||
>
|
||||
{{ group.name }}
|
||||
</UiLink>
|
||||
</UiLinkList>
|
||||
|
||||
<span v-else>{{ t('no-group-attached') }}</span>
|
||||
|
||||
<UiTitle class="section-title">
|
||||
{{ t('user:roles-assignment') }}
|
||||
<template #action>
|
||||
<UiButton left-icon="fa:edit" variant="tertiary" accent="brand" size="medium" @click="emit('edit', 'roles')">
|
||||
{{ t('action:edit') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiTitle>
|
||||
|
||||
<template v-if="roleSections.length > 0">
|
||||
<div v-for="section of roleSections" :key="section.key" class="role-section">
|
||||
<span class="typo-body-bold">{{ section.label }}</span>
|
||||
<UiLinkList variant="vertical">
|
||||
<UiLink
|
||||
v-for="role of section.roles"
|
||||
:key="role.id"
|
||||
icon="fa:key"
|
||||
size="small"
|
||||
:to="{ name: '/admin/user-management/roles', query: { id: role.id } }"
|
||||
>
|
||||
{{ role.name }}
|
||||
</UiLink>
|
||||
</UiLinkList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<span v-else>{{ t('no-role-applied') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FrontXoGroup } from '@/modules/group/remote-resources/use-xo-group-collection.ts'
|
||||
import { type FrontXoRole, useXoRoleCollection } from '@/modules/role/remote-resources/use-xo-role-collection.ts'
|
||||
import UiButton from '@core/components/ui/button/UiButton.vue'
|
||||
import UiLink from '@core/components/ui/link/UiLink.vue'
|
||||
import UiLinkList from '@core/components/ui/link-list/UiLinkList.vue'
|
||||
import UiTitle from '@core/components/ui/title/UiTitle.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type RoleSection = {
|
||||
key: string
|
||||
label: string
|
||||
roles: FrontXoRole[]
|
||||
}
|
||||
|
||||
const { groups, directRoleIds } = defineProps<{
|
||||
details: { username: string; firstname: string; lastname: string; email: string }
|
||||
groups: FrontXoGroup[]
|
||||
directRoleIds: FrontXoRole['id'][]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [step: 'details' | 'groups' | 'roles']
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { getRolesByIds } = useXoRoleCollection()
|
||||
|
||||
const roleSections = computed<RoleSection[]>(() => {
|
||||
const inheritedSections = groups
|
||||
.map(group => ({
|
||||
key: group.id,
|
||||
label: t('user:roles-from-group', { group: group.name }),
|
||||
roles: getRolesByIds(group.aclRoleIds),
|
||||
}))
|
||||
.filter(section => section.roles.length > 0)
|
||||
|
||||
const directRoles = getRolesByIds(directRoleIds)
|
||||
|
||||
if (directRoles.length === 0) {
|
||||
return inheritedSections
|
||||
}
|
||||
|
||||
return [...inheritedSections, { key: 'direct', label: t('user:direct-roles'), roles: directRoles }]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.new-user-review {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 1.6rem 6.4rem;
|
||||
|
||||
> * {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-block-start: 1.6rem;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
row-gap: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--color-neutral-txt-secondary);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.role-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<VtsInputWrapper :label :message="messages" wrap-message>
|
||||
<UiInput v-model="model" accent="brand" :type :required @blur="emit('blur')" />
|
||||
</VtsInputWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
|
||||
import type { InputType } from '@core/components/ui/input/UiInput.vue'
|
||||
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
|
||||
import UiInput from '@core/components/ui/input/UiInput.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { info, warning, error } = defineProps<{
|
||||
label: string
|
||||
type?: InputType
|
||||
info?: string
|
||||
warning?: InputWrapperMessage
|
||||
error?: InputWrapperMessage
|
||||
required?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ blur: [] }>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const messages = computed<InputWrapperMessage>(
|
||||
() => [info, warning, error].filter(message => message !== undefined) as InputWrapperMessage
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useXoUserCreateJob } from '@/modules/user/jobs/xo-user-create.job.ts'
|
||||
import type { NewUserPayload } from '@/modules/user/types.ts'
|
||||
import { ApiError } from '@/shared/error/api.error.ts'
|
||||
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
|
||||
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export function useUserCreate() {
|
||||
const { t } = useI18n()
|
||||
|
||||
const newUser = ref<NewUserPayload>()
|
||||
|
||||
const errorMessage = ref<string>()
|
||||
|
||||
const { run } = useXoUserCreateJob(newUser)
|
||||
|
||||
const { open } = useOverlay({
|
||||
component: () => import('@/modules/user/components/drawer/NewUserDrawer.vue'),
|
||||
events: {
|
||||
onConfirm: async (payload: NewUserPayload) => {
|
||||
newUser.value = payload
|
||||
errorMessage.value = undefined
|
||||
|
||||
try {
|
||||
await run()
|
||||
} catch (error) {
|
||||
console.error('Error when creating user:', error)
|
||||
|
||||
errorMessage.value =
|
||||
error instanceof ApiError && error.status === 409
|
||||
? t('new-user:error-already-exists')
|
||||
: t('new-user:error-message')
|
||||
|
||||
return KEEP_OVERLAY_OPEN
|
||||
}
|
||||
},
|
||||
onCancel: true,
|
||||
},
|
||||
})
|
||||
|
||||
const drawerProps = reactiveComputed(() => ({ errorMessage: errorMessage.value }))
|
||||
|
||||
function createUser() {
|
||||
errorMessage.value = undefined
|
||||
|
||||
return open({ props: drawerProps })
|
||||
}
|
||||
|
||||
return { createUser }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { type FrontXoGroup, useXoGroupCollection } from '@/modules/group/remote-resources/use-xo-group-collection.ts'
|
||||
import type { FrontXoRole } from '@/modules/role/remote-resources/use-xo-role-collection.ts'
|
||||
import type { NewUserPayload } from '@/modules/user/types.ts'
|
||||
import { required, sameAs, withMessage } from '@core/packages/form-validation'
|
||||
import { defineFormSteps, useMultiStepValidatedForm } from '@core/packages/validated-form'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export function getInheritedRoleIds(groups: FrontXoGroup[]): FrontXoRole['id'][] {
|
||||
return Array.from(new Set(groups.flatMap(group => group.aclRoleIds)))
|
||||
}
|
||||
|
||||
export function useNewUserForm() {
|
||||
const { t } = useI18n()
|
||||
|
||||
const formData = defineFormSteps({
|
||||
details: {
|
||||
username: '',
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
},
|
||||
groups: {
|
||||
groupIds: [] as FrontXoGroup['id'][],
|
||||
},
|
||||
roles: {
|
||||
roleIds: [] as FrontXoRole['id'][],
|
||||
},
|
||||
review: {},
|
||||
})
|
||||
|
||||
const { useField, currentStep, next, back, backTo, validateAllSteps } = useMultiStepValidatedForm(formData, {
|
||||
details: {
|
||||
errors: {
|
||||
onSubmit: () => ({
|
||||
username: { required },
|
||||
firstname: { required },
|
||||
lastname: { required },
|
||||
email: { required },
|
||||
password: { required },
|
||||
passwordConfirmation: {
|
||||
required,
|
||||
sameAs: withMessage(
|
||||
sameAs(() => formData.details.password),
|
||||
() => t('form:error:password-mismatch')
|
||||
),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
groups: {},
|
||||
roles: {},
|
||||
review: {},
|
||||
})
|
||||
|
||||
const { useGetGroupsByIds } = useXoGroupCollection()
|
||||
|
||||
const selectedGroups = useGetGroupsByIds(() => formData.groups.groupIds)
|
||||
|
||||
const inheritedRoleIds = computed(() => getInheritedRoleIds(selectedGroups.value))
|
||||
|
||||
const directRoleIds = computed(() =>
|
||||
formData.roles.roleIds.filter(roleId => !inheritedRoleIds.value.includes(roleId))
|
||||
)
|
||||
|
||||
async function validateAndBuildPayload(): Promise<NewUserPayload | undefined> {
|
||||
if (!(await validateAllSteps())) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { username, firstname, lastname, email, password } = formData.details
|
||||
|
||||
return {
|
||||
user: {
|
||||
name: email.trim(),
|
||||
username: username.trim(),
|
||||
firstname: firstname.trim(),
|
||||
lastname: lastname.trim(),
|
||||
password,
|
||||
},
|
||||
groupIds: formData.groups.groupIds,
|
||||
roleIds: directRoleIds.value,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
formData,
|
||||
selectedGroups,
|
||||
inheritedRoleIds,
|
||||
directRoleIds,
|
||||
currentStep,
|
||||
next,
|
||||
back,
|
||||
backTo,
|
||||
usernameInputBindings: useField('username', () => ({ label: t('username'), required: true })),
|
||||
firstnameInputBindings: useField('firstname', () => ({ label: t('first-name'), required: true })),
|
||||
lastnameInputBindings: useField('lastname', () => ({ label: t('last-name'), required: true })),
|
||||
emailInputBindings: useField('email', () => ({ label: t('email-address'), required: true })),
|
||||
passwordInputBindings: useField('password', () => ({
|
||||
label: t('password'),
|
||||
type: 'password' as const,
|
||||
required: true,
|
||||
})),
|
||||
passwordConfirmationInputBindings: useField('passwordConfirmation', () => ({
|
||||
label: t('password-confirmation'),
|
||||
type: 'password' as const,
|
||||
required: true,
|
||||
})),
|
||||
validateAndBuildPayload,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { FrontXoUser } from '@/modules/user/remote-resources/use-xo-user-collection.ts'
|
||||
import type { NewUserPayload } from '@/modules/user/types.ts'
|
||||
import { defineJobArg } from '@core/packages/job'
|
||||
|
||||
export const xoUsersArg = defineJobArg({
|
||||
identify: (user: FrontXoUser) => user.id,
|
||||
toArray: true,
|
||||
})
|
||||
|
||||
export const newUserArg = defineJobArg({
|
||||
identify: (payload: NewUserPayload) => payload.user.name,
|
||||
toArray: false,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useXoGroupCollection } from '@/modules/group/remote-resources/use-xo-group-collection.ts'
|
||||
import { useXoRoleCollection } from '@/modules/role/remote-resources/use-xo-role-collection.ts'
|
||||
import { newUserArg } from '@/modules/user/jobs/xo-user-args.ts'
|
||||
import { type FrontXoUser, useXoUserCollection } from '@/modules/user/remote-resources/use-xo-user-collection.ts'
|
||||
import type { NewUserPayload } from '@/modules/user/types.ts'
|
||||
import { fetchPost, fetchPut } from '@/shared/utils/fetch.util.ts'
|
||||
import { settleSequentially } from '@/shared/utils/promise.util.ts'
|
||||
import { defineJob, JobError, JobRunningError } from '@core/packages/job'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export const useXoUserCreateJob = defineJob('user.create', [newUserArg], () => {
|
||||
const { t } = useI18n()
|
||||
|
||||
const { $context: userContext } = useXoUserCollection()
|
||||
|
||||
const { $context: groupContext } = useXoGroupCollection()
|
||||
|
||||
const { $context: roleContext } = useXoRoleCollection()
|
||||
|
||||
return {
|
||||
async run({ user, groupIds, roleIds }: NewUserPayload) {
|
||||
const { id } = await fetchPost<{ id: FrontXoUser['id'] }>('users', user)
|
||||
|
||||
const results = await Promise.all([
|
||||
settleSequentially(groupIds.map(groupId => () => fetchPut(`groups/${groupId}/users/${id}`))),
|
||||
Promise.allSettled(roleIds.map(roleId => fetchPut(`acl-roles/${roleId}/users/${id}`))),
|
||||
])
|
||||
|
||||
results
|
||||
.flat()
|
||||
.filter(result => result.status === 'rejected')
|
||||
.forEach(result => console.error('Failed to assign a group or a role to the new user:', result.reason))
|
||||
|
||||
userContext.forceReload()
|
||||
groupContext.forceReload()
|
||||
roleContext.forceReload()
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
validate: (isRunning, payload: NewUserPayload | undefined) => {
|
||||
if (isRunning) {
|
||||
throw new JobRunningError(t('job:create:in-progress'))
|
||||
}
|
||||
|
||||
if (payload === undefined) {
|
||||
throw new JobError(t('job:arg:missing-payload'))
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
14
@xen-orchestra/web/src/modules/user/types.ts
Normal file
14
@xen-orchestra/web/src/modules/user/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { FrontXoGroup } from '@/modules/group/remote-resources/use-xo-group-collection.ts'
|
||||
import type { FrontXoRole } from '@/modules/role/remote-resources/use-xo-role-collection.ts'
|
||||
|
||||
export type NewUserPayload = {
|
||||
user: {
|
||||
name: string
|
||||
username: string
|
||||
firstname: string
|
||||
lastname: string
|
||||
password: string
|
||||
}
|
||||
groupIds: FrontXoGroup['id'][]
|
||||
roleIds: FrontXoRole['id'][]
|
||||
}
|
||||
@@ -3,7 +3,13 @@
|
||||
<div class="container">
|
||||
<UserRolesAlert />
|
||||
<UiCard>
|
||||
<UsersTable :users />
|
||||
<UsersTable :users>
|
||||
<template #title-actions>
|
||||
<UiButton variant="primary" accent="brand" size="medium" left-icon="fa:plus" @click="createUser()">
|
||||
{{ t('new') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UsersTable>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +21,20 @@
|
||||
import UserSidePanel from '@/modules/user/components/list/panel/UserSidePanel.vue'
|
||||
import UsersTable from '@/modules/user/components/list/UsersTable.vue'
|
||||
import UserRolesAlert from '@/modules/user/components/UserRolesAlert.vue'
|
||||
import { useUserCreate } from '@/modules/user/composables/use-user-create.composable.ts'
|
||||
import { type FrontXoUser, useXoUserCollection } from '@/modules/user/remote-resources/use-xo-user-collection.ts'
|
||||
import VtsContentSidePanel from '@core/components/layout/VtsContentSidePanel.vue'
|
||||
import UiButton from '@core/components/ui/button/UiButton.vue'
|
||||
import UiCard from '@core/components/ui/card/UiCard.vue'
|
||||
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { users, getUserById } = useXoUserCollection()
|
||||
|
||||
const { createUser } = useUserCreate()
|
||||
|
||||
const selectedUser = useRouteQuery<FrontXoUser | undefined>('id', {
|
||||
toData: id => getUserById(id as FrontXoUser['id']),
|
||||
toQuery: user => user?.id ?? '',
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
- [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
|
||||
- [XO6/Users] Show users by their username, or their email address when they have none, and show their first and last names in the users list and side panel
|
||||
- [XO6/Users] Create a user from the users list in the user management page, and add it to groups and assign it roles right away
|
||||
|
||||
### Bug fixes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user