Creation user management interface

This commit is contained in:
kevin 2026-04-28 11:48:16 +03:00
parent 3dcb88290d
commit 1d18659bd7
11 changed files with 2116 additions and 1 deletions

View File

@ -113,8 +113,13 @@ class UserController extends Controller
} }
$validated = $request->validated(); $validated = $request->validated();
$clearPassword = (bool) ($validated['clear_password'] ?? false);
if (empty($validated['password'])) { unset($validated['clear_password']);
if ($clearPassword) {
$validated['password'] = null;
} elseif (empty($validated['password'])) {
unset($validated['password']); unset($validated['password']);
} }

View File

@ -34,6 +34,7 @@ class UpdateUserRequest extends FormRequest
'permissions' => ['nullable', 'array'], 'permissions' => ['nullable', 'array'],
'permissions.*' => ['string', 'max:150'], 'permissions.*' => ['string', 'max:150'],
'password' => ['nullable', 'string', Password::min(8)], 'password' => ['nullable', 'string', Password::min(8)],
'clear_password' => ['nullable', 'boolean'],
]; ];
} }
} }

View File

@ -0,0 +1,131 @@
<template>
<ClientDetailTemplate>
<template #button-return>
<div class="col-12">
<router-link
to="/parametrage/utilisateurs"
class="btn btn-outline-secondary btn-sm mb-3"
>
<i class="fas fa-arrow-left me-2"></i>Retour à la liste utilisateurs
</router-link>
</div>
</template>
<template #client-detail-sidebar>
<div class="card position-sticky top-1 user-sidebar-card">
<div class="card-body text-center">
<div class="avatar avatar-xxl position-relative mx-auto mb-3">
<div
class="avatar-placeholder w-100 border-radius-lg shadow-sm d-flex align-items-center justify-content-center bg-gradient-info"
>
<span class="text-white text-lg font-weight-bold">+</span>
</div>
</div>
<h5 class="mb-1">Création utilisateur</h5>
<p class="text-sm text-muted mb-0">
Créer un compte puis lui assigner un ou plusieurs rôles.
</p>
</div>
</div>
</template>
<template #client-detail-content>
<div v-if="error" class="alert alert-danger text-white">
{{ error }}
</div>
<div class="row g-4">
<div class="col-12 col-xl-8">
<UserFormPanel
:form="form"
:available-roles="roles"
:available-permissions="permissions"
:disabled="submitDisabled"
heading="Créer un utilisateur"
description="Renseigner les informations du compte et assigner ses rôles dès la création."
submit-label="Créer et assigner"
:show-permissions="false"
@update:field="$emit('update:field', $event)"
@toggle="$emit('toggle-selection', $event)"
@submit="$emit('submit-user')"
@reset="$emit('reset-form')"
/>
</div>
<div class="col-12 col-xl-4">
<div class="card user-create-card h-100">
<div class="card-header pb-0">
<h5 class="mb-1">Aide rapide</h5>
<p class="text-sm text-muted mb-0">
Conseils pour créer un utilisateur prêt à l'emploi.
</p>
</div>
<div class="card-body pt-3">
<div class="help-block">
<p class="text-sm mb-2">
<strong>1.</strong> Définir nom et email.
</p>
<p class="text-sm mb-2">
<strong>2.</strong> Saisir un mot de passe initial.
</p>
<p class="text-sm mb-0">
<strong>3.</strong> Assigner le rôle adapté au périmètre
métier.
</p>
</div>
</div>
</div>
</div>
</div>
</template>
</ClientDetailTemplate>
</template>
<script setup>
import { defineEmits, defineProps } from "vue";
import ClientDetailTemplate from "@/components/templates/CRM/ClientDetailTemplate.vue";
import UserFormPanel from "@/components/molecules/parametrage/users/UserFormPanel.vue";
defineProps({
form: {
type: Object,
required: true,
},
roles: {
type: Array,
default: () => [],
},
permissions: {
type: Array,
default: () => [],
},
error: {
type: String,
default: null,
},
submitDisabled: {
type: Boolean,
default: false,
},
});
defineEmits(["update:field", "toggle-selection", "submit-user", "reset-form"]);
</script>
<style scoped>
.position-sticky {
top: 1rem;
}
.user-sidebar-card,
.user-create-card {
border: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
}
.avatar-placeholder {
width: 100px;
height: 100px;
font-size: 2rem;
}
</style>

View File

@ -0,0 +1,497 @@
<template>
<ClientDetailTemplate>
<template #button-return>
<div class="col-12">
<router-link
to="/parametrage/utilisateurs"
class="btn btn-outline-secondary btn-sm mb-3"
>
<i class="fas fa-arrow-left me-2"></i>Retour à la liste utilisateurs
</router-link>
</div>
</template>
<template #client-detail-sidebar>
<div class="card position-sticky top-1 user-sidebar-card">
<div class="card-body text-center">
<div class="avatar avatar-xxl position-relative mx-auto mb-3">
<div
class="avatar-placeholder w-100 border-radius-lg shadow-sm d-flex align-items-center justify-content-center bg-gradient-dark"
>
<span class="text-white text-lg font-weight-bold">
{{ initials }}
</span>
</div>
</div>
<h5 class="mb-1">{{ user?.name || "Utilisateur" }}</h5>
<p class="text-sm text-muted mb-3">{{ user?.email || "-" }}</p>
<div class="d-flex flex-wrap justify-content-center gap-2 mb-3">
<span
v-for="role in user?.roles || []"
:key="`sidebar-role-${role.id}`"
class="badge bg-gradient-dark"
>
{{ role.name }}
</span>
</div>
</div>
<hr class="horizontal dark my-0 mx-3" />
<div class="card-body pt-3">
<ul class="nav nav-pills flex-column bg-transparent border-radius-lg">
<li class="nav-item">
<a
class="nav-link user-nav-link"
:class="{ active: activeTab === 'overview' }"
href="javascript:;"
@click="$emit('change-tab', 'overview')"
>
<i class="fas fa-eye me-2"></i>
<span class="text-sm me-2">Vue d'ensemble</span>
</a>
</li>
<li class="nav-item pt-2">
<a
class="nav-link user-nav-link"
:class="{ active: activeTab === 'email' }"
href="javascript:;"
@click="$emit('change-tab', 'email')"
>
<i class="fas fa-envelope me-2"></i>
<span class="text-sm me-2">Modification email</span>
</a>
</li>
<li class="nav-item pt-2">
<a
class="nav-link user-nav-link"
:class="{ active: activeTab === 'access' }"
href="javascript:;"
@click="$emit('change-tab', 'access')"
>
<i class="fas fa-shield-alt me-2"></i>
<span class="text-sm me-2">Droits et permissions</span>
</a>
</li>
<li class="nav-item pt-2">
<a
class="nav-link user-nav-link"
:class="{ active: activeTab === 'password' }"
href="javascript:;"
@click="$emit('change-tab', 'password')"
>
<i class="fas fa-key me-2"></i>
<span class="text-sm me-2">Reset password</span>
</a>
</li>
</ul>
</div>
</div>
</template>
<template #client-detail-content>
<div v-if="isLoading" class="text-center p-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div v-else>
<div v-if="error" class="alert alert-danger text-white">
{{ error }}
</div>
<div v-show="activeTab === 'overview'" class="row g-4">
<div class="col-12 col-xl-7">
<div class="card user-detail-card h-100">
<div class="card-header pb-0">
<h5 class="mb-1">Informations générales</h5>
<p class="text-sm text-muted mb-0">
Informations de base et configuration du compte utilisateur.
</p>
</div>
<div class="card-body pt-3">
<div class="row">
<div class="col-12 col-md-6 mb-3">
<p class="text-xs text-uppercase text-muted mb-1">Nom</p>
<h6 class="mb-0">{{ user?.name || "-" }}</h6>
</div>
<div class="col-12 col-md-6 mb-3">
<p class="text-xs text-uppercase text-muted mb-1">Email</p>
<h6 class="mb-0">{{ user?.email || "-" }}</h6>
</div>
<div class="col-12 mb-3">
<p class="text-xs text-uppercase text-muted mb-2">Rôles</p>
<div class="d-flex flex-wrap gap-2">
<span
v-for="role in user?.roles || []"
:key="`overview-role-${role.id}`"
class="badge bg-gradient-dark"
>
{{ role.name }}
</span>
<span
v-if="!(user?.roles || []).length"
class="text-sm text-muted"
>
Aucun rôle assigné
</span>
</div>
</div>
<div class="col-12">
<p class="text-xs text-uppercase text-muted mb-2">
Permissions directes
</p>
<div class="d-flex flex-wrap gap-2">
<span
v-for="permission in user?.permissions || []"
:key="`overview-permission-${permission.id}`"
class="badge bg-gradient-info"
>
{{ permission.name }}
</span>
<span
v-if="!(user?.permissions || []).length"
class="text-sm text-muted"
>
Aucune permission directe
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card user-detail-card h-100">
<div class="card-header pb-0">
<h5 class="mb-1">Actions rapides</h5>
<p class="text-sm text-muted mb-0">
Accès direct aux opérations principales sur ce compte.
</p>
</div>
<div class="card-body pt-3 d-grid gap-3">
<button
type="button"
class="btn btn-outline-info mb-0"
@click="$emit('change-tab', 'email')"
>
Modifier l'email
</button>
<button
type="button"
class="btn btn-outline-dark mb-0"
@click="$emit('change-tab', 'access')"
>
Gérer les accès
</button>
<button
type="button"
class="btn btn-outline-warning mb-0"
@click="$emit('change-tab', 'password')"
>
Reset password
</button>
</div>
</div>
</div>
</div>
<div v-show="activeTab === 'email'" class="row">
<div class="col-12">
<div class="card user-detail-card">
<div class="card-header pb-0">
<h5 class="mb-1">Modification email</h5>
<p class="text-sm text-muted mb-0">
Mettre à jour l'adresse email du compte utilisateur.
</p>
</div>
<div class="card-body pt-3">
<label class="form-label">Nouvelle adresse email</label>
<SoftInput
id="user-detail-email"
:model-value="emailForm"
type="email"
placeholder="Ex: utilisateur@example.com"
@update:model-value="$emit('update:email', $event)"
/>
<div class="d-flex gap-2 mt-4 flex-wrap">
<button
type="button"
class="btn bg-gradient-info mb-0"
:disabled="submitDisabled"
@click="$emit('submit-email')"
>
Enregistrer l'email
</button>
<button
type="button"
class="btn btn-outline-secondary mb-0"
@click="$emit('reset-email')"
>
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
<div v-show="activeTab === 'access'" class="row">
<div class="col-12">
<div class="card user-detail-card">
<div class="card-header pb-0">
<h5 class="mb-1">Rôles et permissions</h5>
<p class="text-sm text-muted mb-0">
Modifier les rôles assignés et les permissions directes de
l'utilisateur.
</p>
</div>
<div class="card-body pt-3">
<div class="mb-4">
<label class="form-label d-block mb-3">Rôles assignés</label>
<div class="access-checkbox-grid">
<label
v-for="role in availableRoles"
:key="`detail-role-${role.id}`"
class="access-checkbox-item"
>
<input
:checked="selectedRoles.includes(role.name)"
type="checkbox"
@change="
$emit('toggle-access', {
field: 'roles',
value: role.name,
checked: $event.target.checked,
})
"
/>
<span>{{ role.name }}</span>
</label>
</div>
</div>
<div>
<label class="form-label d-block mb-3">
Permissions directes
</label>
<div class="access-checkbox-grid">
<label
v-for="permission in availablePermissions"
:key="`detail-permission-${permission.id}`"
class="access-checkbox-item"
>
<input
:checked="selectedPermissions.includes(permission.name)"
type="checkbox"
@change="
$emit('toggle-access', {
field: 'permissions',
value: permission.name,
checked: $event.target.checked,
})
"
/>
<span>{{ permission.name }}</span>
</label>
</div>
</div>
<div class="d-flex gap-2 mt-4 flex-wrap">
<button
type="button"
class="btn bg-gradient-dark mb-0"
:disabled="accessSubmitDisabled"
@click="$emit('submit-access')"
>
Enregistrer les accès
</button>
<button
type="button"
class="btn btn-outline-secondary mb-0"
@click="$emit('reset-access')"
>
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
<div v-show="activeTab === 'password'" class="row">
<div class="col-12">
<div class="card user-detail-card">
<div class="card-header pb-0">
<h5 class="mb-1">Reset password</h5>
<p class="text-sm text-muted mb-0">
Supprimer le mot de passe actuel de ce compte utilisateur.
</p>
</div>
<div class="card-body pt-3">
<div class="alert alert-warning text-white mb-0">
Cette action va supprimer le mot de passe stocké en base pour
cet utilisateur. Une confirmation sera demandée avant
suppression.
</div>
<div class="d-flex gap-2 mt-4 flex-wrap">
<button
type="button"
class="btn bg-gradient-warning mb-0"
:disabled="submitDisabled"
@click="$emit('submit-password')"
>
Supprimer le mot de passe
</button>
<button
type="button"
class="btn btn-outline-secondary mb-0"
@click="$emit('reset-password')"
>
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</ClientDetailTemplate>
</template>
<script setup>
import { computed, defineEmits, defineProps } from "vue";
import ClientDetailTemplate from "@/components/templates/CRM/ClientDetailTemplate.vue";
import SoftInput from "@/components/SoftInput.vue";
const props = defineProps({
user: {
type: Object,
default: null,
},
activeTab: {
type: String,
required: true,
},
emailForm: {
type: String,
default: "",
},
availableRoles: {
type: Array,
default: () => [],
},
availablePermissions: {
type: Array,
default: () => [],
},
selectedRoles: {
type: Array,
default: () => [],
},
selectedPermissions: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
submitDisabled: {
type: Boolean,
default: false,
},
accessSubmitDisabled: {
type: Boolean,
default: false,
},
});
defineEmits([
"change-tab",
"update:email",
"submit-email",
"submit-password",
"reset-email",
"reset-password",
"toggle-access",
"submit-access",
"reset-access",
]);
const initials = computed(() => {
return (props.user?.name || "US")
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("");
});
</script>
<style scoped>
.position-sticky {
top: 1rem;
}
.user-sidebar-card,
.user-detail-card {
border: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
}
.avatar-placeholder {
width: 100px;
height: 100px;
font-size: 2rem;
}
.user-nav-link {
border-radius: 0.5rem;
margin-bottom: 0.25rem;
color: #67748e;
background-color: transparent;
transition: all 0.2s ease-in-out;
}
.user-nav-link:hover {
background-color: #f8f9fa;
}
.user-nav-link.active {
background: linear-gradient(310deg, #1f2937, #4b5563);
color: #fff;
}
.access-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
}
.access-checkbox-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.75rem 0.9rem;
border-radius: 0.85rem;
border: 1px solid rgba(103, 116, 142, 0.15);
background: #fff;
font-size: 0.875rem;
color: #344767;
}
.access-checkbox-item input {
accent-color: #344767;
}
</style>

View File

@ -0,0 +1,336 @@
<template>
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col-12">
<div class="card user-list-hero border-0 overflow-hidden">
<div class="card-body p-4 p-lg-5">
<div class="row align-items-center">
<div class="col-12 col-lg-8">
<p class="text-sm text-uppercase fw-bold opacity-8 mb-2">
Paramétrage
</p>
<h3 class="text-white mb-2">Gestion des utilisateurs</h3>
<p class="text-white-50 mb-0">
Liste paginée des comptes applicatifs avec accès rapide au
détail, à la modification d'email et au reset de mot de passe.
</p>
</div>
<div class="col-12 col-lg-4 mt-4 mt-lg-0 text-lg-end">
<router-link
to="/parametrage/utilisateurs/creation"
class="btn bg-gradient-light mb-0"
>
<i class="fas fa-user-plus me-2"></i>Créer un utilisateur
</router-link>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-12 col-md-4">
<div class="card border-0 h-100 shadow-sm">
<div class="card-body">
<p class="text-xs text-uppercase text-muted mb-2">Utilisateurs</p>
<h4 class="mb-0">{{ totalUsers }}</h4>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card border-0 h-100 shadow-sm">
<div class="card-body">
<p class="text-xs text-uppercase text-muted mb-2">Rôles</p>
<h4 class="mb-0">{{ roleCount }}</h4>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card border-0 h-100 shadow-sm">
<div class="card-body">
<p class="text-xs text-uppercase text-muted mb-2">Page actuelle</p>
<h4 class="mb-0">{{ pagination.current_page }}</h4>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div
class="card-header pb-0 d-flex flex-wrap gap-3 align-items-center justify-content-between"
>
<div>
<h5 class="mb-1">Liste des utilisateurs</h5>
<p class="text-sm text-muted mb-0">
Affichage de {{ pagination.from }} à {{ pagination.to }} sur
{{ totalUsers }} utilisateurs.
</p>
</div>
<div class="user-search-box">
<SoftInput
id="user-list-search"
:model-value="search"
placeholder="Rechercher par nom ou email"
icon="fas fa-search"
icon-dir="left"
@update:model-value="$emit('update:search', $event)"
/>
</div>
</div>
<div class="card-body px-0 pt-0 pb-2">
<div v-if="error" class="alert alert-danger text-white mx-3 mt-3 mb-0">
{{ error }}
</div>
<div v-if="isLoading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div v-else class="table-responsive p-0">
<table class="table align-items-center mb-0">
<thead>
<tr>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-4"
>
Utilisateur
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Email
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Rôles
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 text-center"
>
Permissions
</th>
<th class="text-secondary opacity-7"></th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>
<div class="d-flex px-4 py-3 align-items-center gap-3">
<div
class="avatar avatar-sm border-radius-lg bg-gradient-dark d-flex align-items-center justify-content-center"
>
<span class="text-white text-xs font-weight-bold">
{{ getInitials(user.name) }}
</span>
</div>
<div>
<h6 class="mb-0 text-sm">{{ user.name }}</h6>
<p class="text-xs text-secondary mb-0">#{{ user.id }}</p>
</div>
</div>
</td>
<td>
<p class="text-sm font-weight-bold mb-0">{{ user.email }}</p>
</td>
<td>
<div class="d-flex flex-wrap gap-2 py-2">
<span
v-for="role in user.roles || []"
:key="`role-${user.id}-${role.id}`"
class="badge bg-gradient-dark"
>
{{ role.name }}
</span>
<span
v-if="!(user.roles || []).length"
class="text-xs text-secondary"
>
Aucun rôle
</span>
</div>
</td>
<td class="align-middle text-center text-sm">
<span class="badge bg-gradient-info">
{{ (user.permissions || []).length }}
</span>
</td>
<td class="align-middle pe-4 text-end">
<router-link
:to="`/parametrage/utilisateurs/${user.id}`"
class="text-secondary font-weight-bold text-xs me-3"
>
Détail
</router-link>
<a
href="javascript:;"
class="text-danger font-weight-bold text-xs"
@click="$emit('delete-user', user.id)"
>
Supprimer
</a>
</td>
</tr>
<tr v-if="!users.length">
<td colspan="5" class="text-center py-4">
<p class="text-sm mb-0">Aucun utilisateur trouvé.</p>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="!isLoading && pagination.last_page > 1"
class="d-flex justify-content-between align-items-center mt-3 px-4 flex-wrap gap-3"
>
<div class="text-xs text-secondary font-weight-bold">
Affichage de {{ pagination.from }} à {{ pagination.to }} sur
{{ totalUsers }} utilisateurs
</div>
<nav aria-label="Pagination utilisateurs">
<ul class="pagination pagination-sm pagination-info mb-0">
<li
class="page-item"
:class="{ disabled: pagination.current_page === 1 }"
>
<a
class="page-link"
href="#"
@click.prevent="
$emit('change-page', pagination.current_page - 1)
"
>
<i class="fa fa-angle-left"></i>
</a>
</li>
<li
v-for="page in displayedPages"
:key="`page-${page}`"
class="page-item"
:class="{
active: pagination.current_page === page,
disabled: page === '...',
}"
>
<a
class="page-link"
href="#"
@click.prevent="page !== '...' && $emit('change-page', page)"
>
{{ page }}
</a>
</li>
<li
class="page-item"
:class="{
disabled: pagination.current_page === pagination.last_page,
}"
>
<a
class="page-link"
href="#"
@click.prevent="
$emit('change-page', pagination.current_page + 1)
"
>
<i class="fa fa-angle-right"></i>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, defineEmits, defineProps } from "vue";
import SoftInput from "@/components/SoftInput.vue";
const props = defineProps({
users: {
type: Array,
default: () => [],
},
search: {
type: String,
default: "",
},
isLoading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
totalUsers: {
type: Number,
default: 0,
},
roleCount: {
type: Number,
default: 0,
},
pagination: {
type: Object,
required: true,
},
});
defineEmits(["update:search", "change-page", "delete-user"]);
const displayedPages = computed(() => {
const total = Number(props.pagination?.last_page) || 1;
const current = Number(props.pagination?.current_page) || 1;
if (total <= 5) {
return Array.from({ length: total }, (_, index) => index + 1);
}
if (current <= 3) {
return [1, 2, 3, 4, "...", total];
}
if (current >= total - 2) {
return [1, "...", total - 3, total - 2, total - 1, total];
}
return [1, "...", current - 1, current, current + 1, "...", total];
});
const getInitials = (name = "") => {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("");
};
</script>
<style scoped>
.user-list-hero {
background: linear-gradient(135deg, #111827 0%, #374151 50%, #0ea5e9 100%);
}
.user-search-box {
min-width: 320px;
}
@media (max-width: 991.98px) {
.user-search-box {
min-width: 100%;
}
}
</style>

View File

@ -0,0 +1,320 @@
<template>
<ClientDetailTemplate>
<template #button-return>
<div class="col-12">
<router-link
to="/dashboard-default"
class="btn btn-outline-secondary btn-sm mb-3"
>
<i class="fas fa-arrow-left me-2"></i>Retour au tableau de bord
</router-link>
</div>
</template>
<template #client-detail-sidebar>
<div class="card position-sticky top-1 user-sidebar-card">
<div class="card-body text-center">
<div class="avatar avatar-xxl position-relative mx-auto mb-3">
<div
class="avatar-placeholder w-100 border-radius-lg shadow-sm d-flex align-items-center justify-content-center bg-gradient-dark"
>
<span class="text-white text-lg font-weight-bold">US</span>
</div>
</div>
<h5 class="mb-1">Gestion utilisateurs</h5>
<p class="text-sm text-muted mb-3">
Piloter les comptes applicatifs, rôles et permissions.
</p>
<div class="row gx-2 gy-2 text-start mb-3">
<div class="col-6">
<div class="user-stat-card">
<span class="user-stat-label">Utilisateurs</span>
<strong>{{ userCount }}</strong>
</div>
</div>
<div class="col-6">
<div class="user-stat-card">
<span class="user-stat-label">Rôles</span>
<strong>{{ roleCount }}</strong>
</div>
</div>
</div>
</div>
<hr class="horizontal dark my-0 mx-3" />
<div class="card-body pt-3">
<ul class="nav nav-pills flex-column bg-transparent border-radius-lg">
<li class="nav-item">
<a
class="nav-link user-nav-link"
:class="{ active: activeTab === 'overview' }"
href="javascript:;"
@click="$emit('change-tab', 'overview')"
>
<i class="fas fa-users me-2"></i>
<span class="text-sm me-2">Vue d'ensemble</span>
</a>
</li>
<li class="nav-item pt-2">
<a
class="nav-link user-nav-link"
:class="{ active: activeTab === 'management' }"
href="javascript:;"
@click="$emit('change-tab', 'management')"
>
<i class="fas fa-user-cog me-2"></i>
<span class="text-sm me-2">Utilisateurs</span>
<span class="badge badge-sm bg-gradient-success ms-auto">
{{ userCount }}
</span>
</a>
</li>
</ul>
</div>
</div>
</template>
<template #client-detail-content>
<div v-if="isLoading" class="text-center p-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div v-else>
<div v-if="error" class="alert alert-danger text-white">
{{ error }}
</div>
<div v-show="activeTab === 'overview'" class="row g-4">
<div class="col-12 col-xl-6">
<div class="card user-management-card h-100">
<div class="card-header pb-0">
<h5 class="mb-1">Comptes récents</h5>
<p class="text-sm text-muted mb-0">
Vue rapide des utilisateurs configurés dans la plateforme.
</p>
</div>
<div class="card-body pt-3">
<div
v-for="user in users.slice(0, 5)"
:key="`overview-user-${user.id}`"
class="overview-list-item"
>
<div>
<h6 class="mb-1 text-sm">{{ user.name }}</h6>
<p class="text-xs text-muted mb-0">{{ user.email }}</p>
</div>
<span class="badge bg-gradient-dark">
{{ (user.roles || []).length }} rôle(s)
</span>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card user-management-card h-100">
<div class="card-header pb-0">
<h5 class="mb-1">Permissions disponibles</h5>
<p class="text-sm text-muted mb-0">
Permissions assignables directement ou via rôle.
</p>
</div>
<div class="card-body pt-3">
<div
v-for="permission in permissions.slice(0, 6)"
:key="`overview-permission-${permission.id}`"
class="overview-list-item"
>
<div>
<h6 class="mb-1 text-sm">{{ permission.name }}</h6>
<p class="text-xs text-muted mb-0">
{{ formatRelatedRoles(permission.roles) }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-show="activeTab === 'management'" class="row g-4">
<div class="col-12 col-xl-5">
<UserFormPanel
:form="form"
:available-roles="roles"
:available-permissions="permissions"
:disabled="submitDisabled"
@update:field="$emit('update:field', $event)"
@toggle="$emit('toggle-selection', $event)"
@submit="$emit('submit-user')"
@reset="$emit('reset-form')"
/>
</div>
<div class="col-12 col-xl-7">
<UserListPanel
:users="users"
:search="search"
@edit="$emit('edit-user', $event)"
@delete="$emit('delete-user', $event)"
@update:search="$emit('update:search', $event)"
/>
</div>
</div>
</div>
</template>
</ClientDetailTemplate>
</template>
<script setup>
import { defineEmits, defineProps } from "vue";
import ClientDetailTemplate from "@/components/templates/CRM/ClientDetailTemplate.vue";
import UserFormPanel from "@/components/molecules/parametrage/users/UserFormPanel.vue";
import UserListPanel from "@/components/molecules/parametrage/users/UserListPanel.vue";
defineProps({
activeTab: {
type: String,
required: true,
},
users: {
type: Array,
default: () => [],
},
roles: {
type: Array,
default: () => [],
},
permissions: {
type: Array,
default: () => [],
},
form: {
type: Object,
required: true,
},
search: {
type: String,
default: "",
},
isLoading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
submitDisabled: {
type: Boolean,
default: false,
},
userCount: {
type: Number,
default: 0,
},
roleCount: {
type: Number,
default: 0,
},
});
defineEmits([
"change-tab",
"update:field",
"toggle-selection",
"submit-user",
"reset-form",
"edit-user",
"delete-user",
"update:search",
]);
const formatRelatedRoles = (roles = []) => {
if (!roles.length) {
return "Aucun rôle associé";
}
return roles.map((role) => role.name).join(", ");
};
</script>
<style scoped>
.position-sticky {
top: 1rem;
}
.user-sidebar-card,
.user-management-card {
border: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
}
.avatar-placeholder {
width: 100px;
height: 100px;
font-size: 2rem;
}
.user-nav-link {
border-radius: 0.5rem;
margin-bottom: 0.25rem;
color: #67748e;
background-color: transparent;
transition: all 0.2s ease-in-out;
}
.user-nav-link:hover {
background-color: #f8f9fa;
}
.user-nav-link.active {
background: linear-gradient(310deg, #1f2937, #4b5563);
color: #fff;
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.09),
0 2px 3px -1px rgba(0, 0, 0, 0.07);
}
.user-stat-card {
padding: 0.85rem 1rem;
border-radius: 0.85rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.95),
rgba(244, 247, 250, 0.92)
);
border: 1px solid rgba(103, 116, 142, 0.12);
}
.user-stat-card strong {
display: block;
font-size: 1.35rem;
color: #344767;
}
.user-stat-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8392ab;
margin-bottom: 0.35rem;
}
.overview-list-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.9rem 0;
border-bottom: 1px solid rgba(103, 116, 142, 0.12);
}
.overview-list-item:last-child {
border-bottom: 0;
}
</style>

View File

@ -0,0 +1,209 @@
<template>
<div class="card user-management-card h-100">
<div
class="card-header pb-0 d-flex justify-content-between align-items-start"
>
<div>
<h5 class="mb-1">
{{
heading ||
(form.id ? "Modifier l'utilisateur" : "Créer un utilisateur")
}}
</h5>
<p class="text-sm text-muted mb-0">
{{
description ||
"Définir le compte, ses rôles et ses permissions directes."
}}
</p>
</div>
<button
v-if="form.id"
type="button"
class="btn btn-outline-secondary btn-sm mb-0"
@click="$emit('reset')"
>
Réinitialiser
</button>
</div>
<div class="card-body pt-3">
<div class="row">
<div class="col-12 col-md-6">
<label class="form-label">Nom</label>
<SoftInput
id="user-name"
:model-value="form.name"
placeholder="Ex: Admin User"
@update:model-value="updateField('name', $event)"
/>
</div>
<div class="col-12 col-md-6 mt-3 mt-md-0">
<label class="form-label">Email</label>
<SoftInput
id="user-email"
:model-value="form.email"
placeholder="Ex: admin@example.com"
type="email"
@update:model-value="updateField('email', $event)"
/>
</div>
<div class="col-12 mt-3">
<label class="form-label">
Mot de passe {{ form.id ? "(laisser vide pour conserver)" : "" }}
</label>
<SoftInput
id="user-password"
:model-value="form.password"
placeholder="Ex: password123"
type="password"
@update:model-value="updateField('password', $event)"
/>
</div>
</div>
<div class="mt-4">
<label class="form-label d-block mb-3">Rôles</label>
<div class="user-checkbox-grid">
<label
v-for="role in availableRoles"
:key="`user-role-${role.id}`"
class="user-checkbox-item"
>
<input
:checked="form.roles.includes(role.name)"
type="checkbox"
@change="toggleSelection('roles', role.name, $event)"
/>
<span>{{ role.name }}</span>
</label>
</div>
</div>
<div v-if="showPermissions" class="mt-4">
<label class="form-label d-block mb-3">Permissions directes</label>
<div class="user-checkbox-grid">
<label
v-for="permission in availablePermissions"
:key="`user-permission-${permission.id}`"
class="user-checkbox-item"
>
<input
:checked="form.permissions.includes(permission.name)"
type="checkbox"
@change="toggleSelection('permissions', permission.name, $event)"
/>
<span>{{ permission.name }}</span>
</label>
</div>
</div>
<div class="d-flex gap-2 mt-4 flex-wrap">
<button
type="button"
class="btn bg-gradient-success mb-0"
:disabled="disabled"
@click="$emit('submit')"
>
{{
submitLabel || (form.id ? "Mettre à jour" : "Créer l'utilisateur")
}}
</button>
<button
type="button"
class="btn btn-outline-secondary mb-0"
@click="$emit('reset')"
>
Annuler
</button>
</div>
</div>
</div>
</template>
<script setup>
import { defineEmits, defineProps } from "vue";
import SoftInput from "@/components/SoftInput.vue";
const props = defineProps({
form: {
type: Object,
required: true,
},
availableRoles: {
type: Array,
default: () => [],
},
availablePermissions: {
type: Array,
default: () => [],
},
heading: {
type: String,
default: "",
},
description: {
type: String,
default: "",
},
submitLabel: {
type: String,
default: "",
},
showPermissions: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:field", "toggle", "submit", "reset"]);
const updateField = (field, value) => {
emit("update:field", { field, value });
};
const toggleSelection = (field, value, event) => {
emit("toggle", { field, value, checked: event.target.checked });
};
</script>
<style scoped>
.user-management-card {
border: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
}
.user-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
}
.user-checkbox-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.75rem 0.9rem;
border-radius: 0.85rem;
border: 1px solid rgba(103, 116, 142, 0.15);
background: #fff;
font-size: 0.875rem;
color: #344767;
}
.user-checkbox-item input {
accent-color: #17c1e8;
}
@media (max-width: 991.98px) {
.user-checkbox-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,144 @@
<template>
<div class="card user-management-card h-100">
<div
class="card-header pb-0 d-flex justify-content-between align-items-start gap-3 flex-wrap"
>
<div>
<h5 class="mb-1">Liste des utilisateurs</h5>
<p class="text-sm text-muted mb-0">
Gérer les comptes, leurs rôles et leurs permissions associées.
</p>
</div>
<div class="user-search-box">
<SoftInput
id="user-search"
:model-value="search"
placeholder="Rechercher par nom ou email"
icon="fas fa-search"
icon-dir="left"
@update:model-value="$emit('update:search', $event)"
/>
</div>
</div>
<div class="card-body pt-3">
<div v-if="!users.length" class="text-sm text-muted">
Aucun utilisateur disponible pour le moment.
</div>
<div v-for="user in users" :key="user.id" class="user-record-card">
<div
class="d-flex justify-content-between align-items-start gap-3 flex-wrap"
>
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<h6 class="mb-0">{{ user.name }}</h6>
<span class="badge bg-gradient-light text-dark">
{{ user.email }}
</span>
</div>
<div class="mt-3">
<p class="text-xs text-uppercase text-muted mb-2">Rôles</p>
<div class="d-flex flex-wrap gap-2">
<span
v-for="role in user.roles || []"
:key="`user-role-chip-${user.id}-${role.id}`"
class="badge bg-gradient-dark"
>
{{ role.name }}
</span>
<span
v-if="!(user.roles || []).length"
class="text-sm text-muted"
>
Aucun rôle
</span>
</div>
</div>
<div class="mt-3">
<p class="text-xs text-uppercase text-muted mb-2">Permissions</p>
<div class="d-flex flex-wrap gap-2">
<span
v-for="permission in user.permissions || []"
:key="`user-permission-chip-${user.id}-${permission.id}`"
class="badge bg-gradient-info"
>
{{ permission.name }}
</span>
<span
v-if="!(user.permissions || []).length"
class="text-sm text-muted"
>
Aucune permission directe
</span>
</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button
type="button"
class="btn btn-outline-info btn-sm mb-0"
@click="$emit('edit', user)"
>
Modifier
</button>
<button
type="button"
class="btn btn-outline-danger btn-sm mb-0"
@click="$emit('delete', user.id)"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineEmits, defineProps } from "vue";
import SoftInput from "@/components/SoftInput.vue";
defineProps({
users: {
type: Array,
default: () => [],
},
search: {
type: String,
default: "",
},
});
defineEmits(["edit", "delete", "update:search"]);
</script>
<style scoped>
.user-management-card {
border: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
}
.user-record-card {
padding: 1rem 0;
border-bottom: 1px solid rgba(103, 116, 142, 0.12);
}
.user-record-card:last-child {
border-bottom: 0;
}
.user-search-box {
min-width: 280px;
}
@media (max-width: 991.98px) {
.user-search-box {
min-width: 100%;
}
}
</style>

View File

@ -0,0 +1,105 @@
<template>
<UserCreatePresentation
:form="form"
:roles="accessControlStore.allRoles"
:permissions="accessControlStore.allPermissions"
:error="pageError"
:submit-disabled="submitDisabled"
@update:field="updateField"
@toggle-selection="toggleSelection"
@submit-user="submitUser"
@reset-form="resetForm"
/>
</template>
<script setup>
import { computed, onMounted, reactive } from "vue";
import { useRouter } from "vue-router";
import Swal from "sweetalert2";
import UserCreatePresentation from "@/components/Organism/Parametrage/Users/UserCreatePresentation.vue";
import { useAccessControlStore } from "@/stores/accessControlStore";
import { useUserStore } from "@/stores/userStore";
const router = useRouter();
const userStore = useUserStore();
const accessControlStore = useAccessControlStore();
const form = reactive({
id: null,
name: "",
email: "",
password: "",
roles: [],
permissions: [],
});
const pageError = computed(() => userStore.error || accessControlStore.error);
const submitDisabled = computed(
() =>
userStore.isLoading ||
accessControlStore.isLoading ||
!form.name.trim() ||
!form.email.trim() ||
!form.password.trim()
);
onMounted(async () => {
try {
await accessControlStore.fetchAccessControl();
} catch {
// Errors are surfaced via stores.
}
});
const resetForm = () => {
form.id = null;
form.name = "";
form.email = "";
form.password = "";
form.roles = [];
form.permissions = [];
};
const updateField = ({ field, value }) => {
form[field] = value;
};
const toggleSelection = ({ field, value, checked }) => {
if (checked) {
if (!form[field].includes(value)) {
form[field] = [...form[field], value];
}
return;
}
form[field] = form[field].filter((item) => item !== value);
};
const submitUser = async () => {
try {
const user = await userStore.createUser({
name: form.name.trim(),
email: form.email.trim(),
password: form.password.trim(),
roles: form.roles,
permissions: form.permissions,
});
await Swal.fire({
icon: "success",
title: "Succès",
text: "L'utilisateur a été créé avec succès.",
confirmButtonText: "Voir le détail",
});
router.push(`/parametrage/utilisateurs/${user.id}`);
} catch {
await Swal.fire({
icon: "error",
title: "Erreur",
text: userStore.error || "Impossible de créer l'utilisateur.",
confirmButtonText: "Fermer",
});
}
};
</script>

View File

@ -0,0 +1,236 @@
<template>
<UserDetailPresentation
:user="userStore.selectedUser"
:active-tab="activeTab"
:email-form="emailForm"
:available-roles="accessControlStore.allRoles"
:available-permissions="accessControlStore.allPermissions"
:selected-roles="accessRoles"
:selected-permissions="accessPermissions"
:is-loading="userStore.isLoading"
:error="userStore.error"
:submit-disabled="submitDisabled"
:access-submit-disabled="accessSubmitDisabled"
@change-tab="activeTab = $event"
@update:email="emailForm = $event"
@submit-email="submitEmail"
@submit-password="submitPassword"
@reset-email="resetEmail"
@reset-password="resetPassword"
@toggle-access="toggleAccess"
@submit-access="submitAccess"
@reset-access="resetAccess"
/>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import Swal from "sweetalert2";
import UserDetailPresentation from "@/components/Organism/Parametrage/Users/UserDetailPresentation.vue";
import { useAccessControlStore } from "@/stores/accessControlStore";
import { useUserStore } from "@/stores/userStore";
const route = useRoute();
const userStore = useUserStore();
const accessControlStore = useAccessControlStore();
const userId = Number(route.params.id);
const activeTab = ref("overview");
const emailForm = ref("");
const accessRoles = ref([]);
const accessPermissions = ref([]);
const submitDisabled = computed(() => {
if (activeTab.value === "email") {
return userStore.isLoading || !emailForm.value.trim();
}
if (activeTab.value === "password") {
return userStore.isLoading;
}
return userStore.isLoading;
});
const accessSubmitDisabled = computed(() => userStore.isLoading);
onMounted(async () => {
try {
const [user] = await Promise.all([
userStore.fetchUser(userId),
accessControlStore.fetchAccessControl(),
]);
emailForm.value = user.email;
accessRoles.value = (user.roles || []).map((role) => role.name);
accessPermissions.value = (user.permissions || []).map(
(permission) => permission.name
);
} catch {
// Error already exposed by store.
}
});
const resetEmail = () => {
emailForm.value = userStore.selectedUser?.email || "";
activeTab.value = "overview";
};
const resetPassword = () => {
activeTab.value = "overview";
};
const resetAccess = () => {
accessRoles.value = (userStore.selectedUser?.roles || []).map(
(role) => role.name
);
accessPermissions.value = (userStore.selectedUser?.permissions || []).map(
(permission) => permission.name
);
activeTab.value = "overview";
};
const applyAccessState = (user) => {
accessRoles.value = (user?.roles || []).map((role) => role.name);
accessPermissions.value = (user?.permissions || []).map(
(permission) => permission.name
);
};
const toggleAccess = ({ field, value, checked }) => {
const target = field === "roles" ? accessRoles : accessPermissions;
if (checked) {
if (!target.value.includes(value)) {
target.value = [...target.value, value];
}
return;
}
target.value = target.value.filter((item) => item !== value);
};
const submitEmail = async () => {
const user = userStore.selectedUser;
if (!user) {
return;
}
try {
await userStore.updateUser({
id: user.id,
name: user.name,
email: emailForm.value.trim(),
roles: (user.roles || []).map((role) => role.name),
permissions: (user.permissions || []).map(
(permission) => permission.name
),
});
await Swal.fire({
icon: "success",
title: "Succès",
text: "L'email a été mis à jour avec succès.",
confirmButtonText: "Fermer",
});
activeTab.value = "overview";
} catch {
await Swal.fire({
icon: "error",
title: "Erreur",
text: userStore.error || "Impossible de mettre à jour l'email.",
confirmButtonText: "Fermer",
});
}
};
const submitPassword = async () => {
const user = userStore.selectedUser;
if (!user) {
return;
}
const result = await Swal.fire({
icon: "warning",
title: "Supprimer le mot de passe ?",
text:
"Cette action va supprimer le mot de passe en base pour cet utilisateur.",
showCancelButton: true,
confirmButtonText: "Oui, supprimer",
cancelButtonText: "Annuler",
});
if (!result.isConfirmed) {
return;
}
try {
await userStore.updateUser({
id: user.id,
name: user.name,
email: user.email,
clear_password: true,
roles: (user.roles || []).map((role) => role.name),
permissions: (user.permissions || []).map(
(permission) => permission.name
),
});
resetPassword();
await Swal.fire({
icon: "success",
title: "Succès",
text: "Le mot de passe a été réinitialisé avec succès.",
confirmButtonText: "Fermer",
});
} catch {
await Swal.fire({
icon: "error",
title: "Erreur",
text: userStore.error || "Impossible de réinitialiser le mot de passe.",
confirmButtonText: "Fermer",
});
}
};
const submitAccess = async () => {
const user = userStore.selectedUser;
if (!user) {
return;
}
try {
const updatedUser = await userStore.updateUser({
id: user.id,
name: user.name,
email: user.email,
roles: accessRoles.value,
permissions: accessPermissions.value,
});
applyAccessState(updatedUser);
await Swal.fire({
icon: "success",
title: "Succès",
text: "Les rôles et permissions ont été mis à jour avec succès.",
confirmButtonText: "Fermer",
});
activeTab.value = "overview";
} catch {
await Swal.fire({
icon: "error",
title: "Erreur",
text: userStore.error || "Impossible de mettre à jour les accès.",
confirmButtonText: "Fermer",
});
}
};
</script>

View File

@ -0,0 +1,131 @@
<template>
<UserListPresentation
:users="paginatedUsers"
:search="search"
:is-loading="userStore.isLoading"
:error="userStore.error"
:total-users="filteredUsers.length"
:role-count="accessControlStore.allRoles.length"
:pagination="pagination"
@update:search="updateSearch"
@change-page="changePage"
@delete-user="deleteUser"
/>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import Swal from "sweetalert2";
import UserListPresentation from "@/components/Organism/Parametrage/Users/UserListPresentation.vue";
import { useAccessControlStore } from "@/stores/accessControlStore";
import { useUserStore } from "@/stores/userStore";
const userStore = useUserStore();
const accessControlStore = useAccessControlStore();
const search = ref("");
const currentPage = ref(1);
const perPage = 10;
const filteredUsers = computed(() => {
const query = search.value.trim().toLowerCase();
if (!query) {
return userStore.allUsers;
}
return userStore.allUsers.filter((user) => {
return (
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
});
});
const paginatedUsers = computed(() => {
const start = (currentPage.value - 1) * perPage;
return filteredUsers.value.slice(start, start + perPage);
});
const pagination = computed(() => {
const total = filteredUsers.value.length;
const lastPage = Math.max(1, Math.ceil(total / perPage));
const safeCurrentPage = Math.min(currentPage.value, lastPage);
const from = total === 0 ? 0 : (safeCurrentPage - 1) * perPage + 1;
const to = Math.min(safeCurrentPage * perPage, total);
return {
current_page: safeCurrentPage,
last_page: lastPage,
per_page: perPage,
total,
from,
to,
};
});
onMounted(async () => {
try {
await Promise.all([
userStore.fetchUsers(),
accessControlStore.fetchAccessControl(),
]);
} catch {
// Errors are surfaced via stores.
}
});
const updateSearch = (value) => {
search.value = value;
currentPage.value = 1;
};
const changePage = (page) => {
if (
page < 1 ||
page > pagination.value.last_page ||
page === currentPage.value
) {
return;
}
currentPage.value = page;
};
const deleteUser = async (id) => {
const result = await Swal.fire({
title: "Supprimer cet utilisateur ?",
text: "Cette action retirera définitivement le compte utilisateur.",
icon: "warning",
showCancelButton: true,
confirmButtonText: "Supprimer",
cancelButtonText: "Annuler",
});
if (!result.isConfirmed) {
return;
}
try {
await userStore.deleteUser(id);
await userStore.fetchUsers();
if (currentPage.value > pagination.value.last_page) {
currentPage.value = pagination.value.last_page;
}
await Swal.fire({
icon: "success",
title: "Succès",
text: "L'utilisateur a été supprimé avec succès.",
confirmButtonText: "Fermer",
});
} catch {
await Swal.fire({
icon: "error",
title: "Erreur",
text: userStore.error || "Impossible de supprimer l'utilisateur.",
confirmButtonText: "Fermer",
});
}
};
</script>