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:
Thierry Goettelmann
2026-11-09 08:13:13 +01:00
committed by GitHub
parent 25b837ef1a
commit 5ad0691574
16 changed files with 852 additions and 130 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }
}

View File

@@ -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,
}
}

View File

@@ -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,
})

View File

@@ -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'))
}
},
}
})

View 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'][]
}

View File

@@ -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 ?? '',

View File

@@ -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