feat(permissions) : add role-based UI guards and readonly mode for viewers

- Add usePermissions composable (isAdmin, canEdit, canView)
- Password-protected profile login with modal on profiles page
- Disable all form fields for ROLE_VIEWER across edit/create pages
- Show navigation buttons (Modifier/Consulter) for all roles, hide delete for viewers
- Add readonly prop to ModelTypeForm for category pages
- Disable modal fields (sites, constructeurs) for viewers
- Guard /admin routes in middleware

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-02-26 13:36:42 +01:00
parent 6bed715b7f
commit cc70fe2b29
46 changed files with 946 additions and 423 deletions

245
app/pages/admin/index.vue Normal file
View File

@@ -0,0 +1,245 @@
<template>
<div class="container mx-auto p-6 max-w-6xl">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">
Administration des profils
</h1>
<button class="btn btn-primary btn-sm" @click="showCreateDialog = true">
Nouveau profil
</button>
</div>
<div v-if="loading" class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg" />
</div>
<div v-else-if="profiles.length" class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Nom</th>
<th>Email</th>
<th>Role</th>
<th>Mot de passe</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="profile in profiles" :key="profile.id">
<td class="font-medium">
{{ profile.firstName }} {{ profile.lastName }}
</td>
<td class="text-sm text-base-content/70">
{{ profile.email || '-' }}
</td>
<td>
<select
class="select select-bordered select-xs"
:value="primaryRole(profile)"
@change="handleRoleChange(profile.id, $event.target.value)"
>
<option value="ROLE_ADMIN">
Admin
</option>
<option value="ROLE_GESTIONNAIRE">
Gestionnaire
</option>
<option value="ROLE_VIEWER">
Viewer
</option>
</select>
</td>
<td>
<span v-if="profile.hasPassword" class="badge badge-success badge-sm">Oui</span>
<span v-else class="badge badge-ghost badge-sm">Non</span>
<button
class="btn btn-ghost btn-xs ml-1"
@click="openPasswordDialog(profile.id)"
>
{{ profile.hasPassword ? 'Changer' : 'Definir' }}
</button>
</td>
<td>
<span
class="badge badge-sm"
:class="profile.isActive ? 'badge-success' : 'badge-error'"
>
{{ profile.isActive ? 'Actif' : 'Inactif' }}
</span>
</td>
<td>
<button
v-if="profile.isActive"
class="btn btn-ghost btn-xs text-error"
@click="handleDeactivate(profile.id)"
>
Desactiver
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="text-center py-12 text-base-content/60">
Aucun profil.
</div>
<!-- Create Profile Dialog -->
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Nouveau profil
</h3>
<form @submit.prevent="handleCreate">
<div class="form-control mb-3">
<label class="label"><span class="label-text">Prenom</span></label>
<input v-model="createForm.firstName" type="text" class="input input-bordered" required>
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="createForm.lastName" type="text" class="input input-bordered" required>
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Email</span></label>
<input v-model="createForm.email" type="email" class="input input-bordered">
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Mot de passe</span></label>
<input v-model="createForm.password" type="password" class="input input-bordered">
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Role</span></label>
<select v-model="createForm.role" class="select select-bordered">
<option value="ROLE_ADMIN">
Admin
</option>
<option value="ROLE_GESTIONNAIRE">
Gestionnaire
</option>
<option value="ROLE_VIEWER">
Viewer
</option>
</select>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="showCreateDialog = false">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="creating">
<span v-if="creating" class="loading loading-spinner loading-xs" />
Creer
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" @click="showCreateDialog = false">
close
</button>
</form>
</dialog>
<!-- Set Password Dialog -->
<dialog ref="passwordDialog" class="modal" :open="showPasswordDialog || undefined">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Definir le mot de passe
</h3>
<form @submit.prevent="handleSetPassword">
<div class="form-control mb-3">
<label class="label"><span class="label-text">Nouveau mot de passe</span></label>
<input v-model="newPassword" type="password" class="input input-bordered" required>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="showPasswordDialog = false">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="settingPassword">
<span v-if="settingPassword" class="loading loading-spinner loading-xs" />
Valider
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" @click="showPasswordDialog = false">
close
</button>
</form>
</dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAdminProfiles } from '#imports'
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
const showCreateDialog = ref(false)
const showPasswordDialog = ref(false)
const creating = ref(false)
const settingPassword = ref(false)
const passwordProfileId = ref(null)
const newPassword = ref('')
const createForm = ref({
firstName: '',
lastName: '',
email: '',
password: '',
role: 'ROLE_VIEWER',
})
const primaryRole = (profile) => {
const roles = profile.roles || []
if (roles.includes('ROLE_ADMIN')) { return 'ROLE_ADMIN' }
if (roles.includes('ROLE_GESTIONNAIRE')) { return 'ROLE_GESTIONNAIRE' }
return 'ROLE_VIEWER'
}
const handleCreate = async () => {
creating.value = true
try {
const data = { ...createForm.value }
if (!data.email) { delete data.email }
if (!data.password) { delete data.password }
await createProfile(data)
showCreateDialog.value = false
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
} finally {
creating.value = false
}
}
const handleRoleChange = async (profileId, role) => {
await updateRole(profileId, role)
}
const openPasswordDialog = (profileId) => {
passwordProfileId.value = profileId
newPassword.value = ''
showPasswordDialog.value = true
}
const handleSetPassword = async () => {
if (!passwordProfileId.value) { return }
settingPassword.value = true
try {
await setPassword(passwordProfileId.value, newPassword.value)
showPasswordDialog.value = false
} finally {
settingPassword.value = false
}
}
const handleDeactivate = async (profileId) => {
await deactivateProfile(profileId)
}
onMounted(() => {
fetchAll()
})
</script>

View File

@@ -149,6 +149,7 @@
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="loadingComposants"
@@ -185,6 +186,7 @@ import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
const { showError } = useToast()
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes()

View File

@@ -26,6 +26,7 @@
:initial-data="initialData"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
@@ -47,6 +48,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
@@ -128,6 +130,7 @@ const handleCancel = () => {
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}

View File

@@ -20,6 +20,7 @@
initial-category="COMPONENT"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({
title: 'Nouvelle catégorie de composant',
}))
@@ -50,6 +53,7 @@ const handleCancel = () => {
}
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true
try {
const enrichedPayload = {

View File

@@ -72,7 +72,7 @@
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
@@ -88,7 +88,7 @@
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
</div>
@@ -100,7 +100,7 @@
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="saving"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
@@ -118,7 +118,7 @@
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
</div>
@@ -277,7 +277,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<input
v-else-if="field.type === 'number'"
@@ -286,14 +286,14 @@
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<option value="">Sélectionner...</option>
<option
@@ -311,7 +311,7 @@
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="saving"
:disabled="!canEdit || saving"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
@@ -321,7 +321,7 @@
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<input
v-else
@@ -329,7 +329,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
</div>
</div>
@@ -347,7 +347,7 @@
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
@@ -419,6 +419,7 @@
Télécharger
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments"
@@ -566,6 +567,7 @@ interface ComponentCatalogType extends ModelType {
customFields?: Array<Record<string, any>>
}
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { get } = useApi()
@@ -746,6 +748,7 @@ const requiredCustomFieldsFilled = computed(() =>
)
const canSubmit = computed(() => Boolean(
canEdit.value &&
component.value &&
editionForm.name &&
requiredCustomFieldsFilled.value &&

View File

@@ -28,7 +28,7 @@
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="loadingTypes || submitting"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
@@ -45,7 +45,7 @@
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
@@ -61,7 +61,7 @@
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
@@ -73,7 +73,7 @@
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
@@ -90,7 +90,7 @@
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
@@ -244,7 +244,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<input
v-else-if="field.type === 'number'"
@@ -253,14 +253,14 @@
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<option value="">Sélectionner...</option>
<option
@@ -278,7 +278,7 @@
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
@@ -288,7 +288,7 @@
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<input
v-else
@@ -296,7 +296,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
</div>
</div>
@@ -314,7 +314,7 @@
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': submitting }">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
@@ -401,6 +401,7 @@ const {
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
@@ -755,6 +756,7 @@ const requiredCustomFieldsFilled = computed(() =>
)
const canSubmit = computed(() => Boolean(
canEdit.value &&
selectedType.value &&
creationForm.name &&
requiredCustomFieldsFilled.value &&

View File

@@ -9,7 +9,7 @@
Gérez les fournisseurs et leurs coordonnées.
</p>
</div>
<button class="btn btn-primary" @click="openCreateModal">
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Nouveau fournisseur
</button>
@@ -73,9 +73,9 @@
<td class="text-right">
<div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
Modifier
{{ canEdit ? 'Modifier' : 'Consulter' }}
</button>
<button class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
Supprimer
</button>
</div>
@@ -90,22 +90,22 @@
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} fournisseur
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
</h3>
<form class="space-y-4" @submit.prevent="saveConstructeur">
<div class="form-control">
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="form.name" type="text" class="input input-bordered" required>
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FieldEmail v-model="form.email" label="Email" />
<FieldPhone v-model="form.phone" label="Téléphone" />
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
</div>
<div class="modal-action">
<button type="button" class="btn" @click="closeModal">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
<button type="submit" class="btn btn-primary" :disabled="!canEdit || saving">
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
</button>
@@ -117,7 +117,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
@@ -126,6 +126,7 @@ import { usePersistedValue } from '~/composables/usePersistedValue'
import { formatPhone } from '~/utils/formatters/phone'
import IconLucidePlus from '~icons/lucide/plus'
const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
const { showError } = useToast()
@@ -221,7 +222,7 @@ const confirmDelete = async (constructeur) => {
}
}
loadConstructeurs()
onMounted(() => loadConstructeurs())
</script>
<style scoped>

View File

@@ -104,10 +104,11 @@
Commencez par ajouter des sites et des machines.
</p>
<div class="flex gap-2 justify-center">
<button class="btn btn-primary" @click="showAddSiteModal = true">
<button v-if="canEdit" class="btn btn-primary" @click="showAddSiteModal = true">
Ajouter un site
</button>
<button
v-if="canEdit"
class="btn btn-secondary"
@click="showAddMachineModal = true"
>
@@ -239,12 +240,14 @@
<div class="card-actions justify-end mt-3">
<button
v-if="canEdit"
class="btn btn-xs btn-outline"
@click.stop="editMachine(machine)"
>
Modifier
</button>
<button
v-if="canEdit"
class="btn btn-xs btn-error"
@click.stop="confirmDeleteMachine(machine)"
>
@@ -277,6 +280,7 @@
Aucune machine dans ce site
</p>
<button
v-if="canEdit"
class="btn btn-sm btn-primary"
@click="addMachineToSite(site)"
>
@@ -304,11 +308,12 @@
type="text"
placeholder="Ex: Usine de production"
class="input input-bordered"
:disabled="!canEdit"
required
>
</div>
<SiteContactFormFields :form="newSite" />
<SiteContactFormFields :form="newSite" :disabled="!canEdit" />
<div class="modal-action">
<button
@@ -318,7 +323,7 @@
>
Annuler
</button>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
Créer le site
</button>
</div>
@@ -343,6 +348,7 @@
type="text"
placeholder="Ex: Presse hydraulique #1"
class="input input-bordered"
:disabled="!canEdit"
required
>
</div>
@@ -354,6 +360,7 @@
<select
v-model="newMachine.siteId"
class="select select-bordered"
:disabled="!canEdit"
required
>
<option value="">
@@ -374,6 +381,7 @@
<select
v-model="newMachine.typeMachineId"
class="select select-bordered"
:disabled="!canEdit"
required
>
<option value="">
@@ -398,6 +406,7 @@
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
:disabled="!canEdit"
>
</div>
</div>
@@ -446,7 +455,7 @@
>
Annuler
</button>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
Créer la machine
</button>
</div>
@@ -474,6 +483,7 @@ import IconLucideTag from '~icons/lucide/tag'
import { formatPhone } from '~/utils/formatters/phone'
import { extractRelationId } from '~/shared/apiRelations'
const { canEdit } = usePermissions()
const { sites, loading, loadSites, createSite } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()

View File

@@ -68,6 +68,7 @@
</div>
<div class="card-actions justify-end mt-4">
<button
v-if="canEdit"
class="btn btn-sm btn-error"
@click.stop="confirmDeleteType(type)"
>
@@ -108,6 +109,7 @@ import IconLucidePackage from "~icons/lucide/package";
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
import IconLucideBox from "~icons/lucide/box";
const { canEdit } = usePermissions();
const { machineTypes, loadMachineTypes, deleteMachineType } =
useMachineTypesApi();

View File

@@ -19,15 +19,17 @@
</div>
</div>
<TypeEditForm
:key="formKey"
v-model="draftType"
:saving="creating"
:resettable="false"
submit-label="Créer le type"
submit-loading-label="Création..."
@submit="handleSubmit"
/>
<div :class="{ 'pointer-events-none opacity-60': !canEdit }">
<TypeEditForm
:key="formKey"
v-model="draftType"
:saving="!canEdit || creating"
:resettable="false"
submit-label="Créer le type"
submit-loading-label="Création..."
@submit="handleSubmit"
/>
</div>
</div>
</div>
@@ -94,6 +96,7 @@ import IconLucideBox from '~icons/lucide/box'
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
const { showError } = useToast()
const { canEdit } = usePermissions()
const formKey = ref(0)
const creating = ref(false)

View File

@@ -163,6 +163,7 @@ import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
const route = useRoute()
const machineId = route.params.id
const { canEdit } = usePermissions()
if (!machineId) {
console.error('ID de machine manquant')
@@ -212,7 +213,7 @@ onMounted(() => {
d.loadMachineData()
d.loadInitialData()
if (route.query.edit === 'true') {
if (route.query.edit === 'true' && canEdit.value) {
d.isEditMode.value = true
}
})

View File

@@ -118,7 +118,7 @@
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)">
Modifier
</button>
<button class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
<button v-if="canEdit" class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
Supprimer
</button>
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary">
@@ -144,6 +144,7 @@ import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag'
const { canEdit } = usePermissions()
const { machines, loading, loadMachines, deleteMachine } = useMachines()
const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()

View File

@@ -30,6 +30,7 @@
type="text"
placeholder="Ex: Presse hydraulique #1"
class="input input-bordered"
:disabled="!canEdit"
required
>
</div>
@@ -38,7 +39,7 @@
<label class="label" for="machine-field-site">
<span class="label-text">Site</span>
</label>
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" required>
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" :disabled="!canEdit" required>
<option value="">
Sélectionner un site
</option>
@@ -58,6 +59,7 @@
v-model="c.newMachine.typeMachineId"
:options="c.machineTypes"
:loading="c.machineTypesLoading"
:disabled="!canEdit"
placeholder="Rechercher un type…"
empty-text="Aucun type trouvé"
:option-label="c.machineTypeLabel"
@@ -74,6 +76,7 @@
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
:disabled="!canEdit"
>
</div>
</div>
@@ -171,7 +174,7 @@
<button
type="submit"
class="btn btn-primary"
:disabled="!c.canCreateMachine || c.submitting"
:disabled="!canEdit || !c.canCreateMachine || c.submitting"
:class="{ loading: c.submitting }"
>
Créer la machine
@@ -194,4 +197,5 @@ import RequirementProductSelector from '~/components/machine/create/RequirementP
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
const c = proxyRefs(useMachineCreatePage())
const { canEdit } = usePermissions()
</script>

View File

@@ -26,6 +26,7 @@
:initial-data="initialData"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
@@ -47,6 +48,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
@@ -126,6 +128,7 @@ const handleCancel = () => {
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}

View File

@@ -20,6 +20,7 @@
initial-category="PIECE"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({
title: 'Nouvelle catégorie de pièce',
}))
@@ -50,6 +53,7 @@ const handleCancel = () => {
}
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true
try {
const enrichedPayload = {

View File

@@ -171,6 +171,7 @@
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="loadingPieces"
@@ -207,6 +208,7 @@ import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
const { showError } = useToast()
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes()

View File

@@ -72,7 +72,7 @@
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
@@ -88,7 +88,7 @@
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
</div>
@@ -100,7 +100,7 @@
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="saving"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
@@ -118,7 +118,7 @@
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
</div>
@@ -159,7 +159,7 @@
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="saving"
:disabled="!canEdit || saving"
:type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
@@ -224,7 +224,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<input
v-else-if="field.type === 'number'"
@@ -233,14 +233,14 @@
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<option value="">Sélectionner...</option>
<option
@@ -258,7 +258,7 @@
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="saving"
:disabled="!canEdit || saving"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
@@ -268,7 +268,7 @@
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<input
v-else
@@ -276,7 +276,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
</div>
</div>
@@ -294,7 +294,7 @@
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
@@ -366,6 +366,7 @@
Télécharger
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments"
@@ -511,6 +512,7 @@ interface PieceCatalogType extends ModelType {
customFields?: Array<Record<string, any>>
}
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { get } = useApi()
@@ -731,6 +733,7 @@ const requiredCustomFieldsFilled = computed(() =>
const canSubmit = computed(() =>
Boolean(
canEdit.value &&
piece.value &&
editionForm.name &&
requiredCustomFieldsFilled.value &&

View File

@@ -28,7 +28,7 @@
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="loadingTypes || submitting"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
@@ -45,7 +45,7 @@
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
@@ -61,7 +61,7 @@
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
@@ -73,7 +73,7 @@
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
@@ -90,7 +90,7 @@
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
@@ -131,7 +131,7 @@
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
:type-product-id="entry.typeProductId"
helper-text="Un produit est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
@@ -196,7 +196,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<input
v-else-if="field.type === 'number'"
@@ -205,14 +205,14 @@
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<option value="">Sélectionner...</option>
<option
@@ -230,7 +230,7 @@
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
@@ -240,7 +240,7 @@
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<input
v-else
@@ -248,7 +248,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
</div>
</div>
@@ -266,7 +266,7 @@
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': submitting }">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
@@ -329,6 +329,7 @@ const { createPiece } = usePieces()
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
@@ -478,6 +479,7 @@ const requiredCustomFieldsFilled = computed(() =>
const canSubmit = computed(() =>
Boolean(
canEdit.value &&
selectedType.value &&
creationForm.name &&
requiredCustomFieldsFilled.value &&

View File

@@ -153,6 +153,7 @@
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)"
@@ -179,6 +180,8 @@ import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
useHead(() => ({
title: 'Catalogue des produits',
}))

View File

@@ -26,6 +26,7 @@
:initial-data="initialData"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
@@ -47,6 +48,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
@@ -126,6 +128,7 @@ const handleCancel = () => {
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}

View File

@@ -20,6 +20,7 @@
initial-category="PRODUCT"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({
title: 'Nouvelle catégorie de produit',
}))
@@ -50,6 +53,7 @@ const handleCancel = () => {
}
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true
try {
const enrichedPayload = {

View File

@@ -64,7 +64,7 @@
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
:disabled="!canEdit || saving"
required
>
</div>
@@ -79,7 +79,7 @@
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
:disabled="!canEdit || saving"
>
</div>
@@ -90,7 +90,7 @@
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="saving"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []"
/>
@@ -108,7 +108,7 @@
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
:disabled="!canEdit || saving"
>
</div>
</div>
@@ -148,7 +148,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<input
v-else-if="field.type === 'number'"
@@ -157,14 +157,14 @@
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<option value="">Sélectionner...</option>
<option
@@ -182,7 +182,7 @@
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="saving"
:disabled="!canEdit || saving"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
@@ -192,7 +192,7 @@
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
<input
v-else
@@ -200,7 +200,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
:disabled="!canEdit || saving"
>
</div>
</div>
@@ -218,7 +218,7 @@
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
@@ -286,6 +286,7 @@
Télécharger
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments || saving"
@@ -424,6 +425,7 @@ import {
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const toast = useToast()
@@ -489,7 +491,7 @@ const requiredCustomFieldsFilled = computed(() =>
)
const canSubmit = computed(() =>
Boolean(product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
)
const structurePreview = computed(() => formatProductStructurePreview(structure.value))

View File

@@ -28,7 +28,7 @@
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="loadingTypes || submitting"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
Chargement des catégories
@@ -45,7 +45,7 @@
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
@@ -61,7 +61,7 @@
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
@@ -73,7 +73,7 @@
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
@@ -90,7 +90,7 @@
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="submitting || !selectedType"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
@@ -135,7 +135,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<input
v-else-if="field.type === 'number'"
@@ -144,14 +144,14 @@
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<option value="">Sélectionner...</option>
<option
@@ -169,7 +169,7 @@
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
@@ -179,7 +179,7 @@
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
<input
v-else
@@ -187,7 +187,7 @@
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="submitting"
:disabled="!canEdit || submitting"
>
</div>
</div>
@@ -205,7 +205,7 @@
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': submitting || uploadingDocuments }">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
@@ -267,6 +267,7 @@ const { createProduct } = useProducts()
const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
@@ -346,6 +347,7 @@ const requiredCustomFieldsFilled = computed(() =>
)
const canSubmit = computed(() => Boolean(
canEdit.value &&
selectedType.value &&
creationForm.name.trim().length >= 2 &&
requiredCustomFieldsFilled.value &&

View File

@@ -1,55 +1,74 @@
<template>
<main class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div class="w-full max-w-2xl">
<div class="w-full max-w-md">
<div class="card bg-base-100 shadow-2xl">
<div class="card-body">
<h1 class="text-2xl font-bold mb-2">
Choisir un profil
<h1 class="text-2xl font-bold mb-6 text-center">
Connexion
</h1>
<p class="text-sm text-base-content/70 mb-6">
Sélectionnez votre profil pour accéder à l'application. La création et la gestion se font via le menu utilisateur.
</p>
<section class="space-y-4">
<header class="flex items-center justify-between">
<h2 class="font-semibold">
Profils disponibles
</h2>
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="loadingProfiles"
@click="refreshProfiles"
>
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs" />
<span v-else>Rafraîchir</span>
</button>
</header>
<div v-if="loadingProfiles" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg" />
</div>
<div v-if="profiles.length" class="space-y-2 max-h-64 overflow-y-auto">
<button
v-for="profile in profiles"
:key="profile.id"
type="button"
class="btn btn-outline btn-sm w-full justify-between"
@click="selectProfile(profile.id)"
<form v-else @submit.prevent="handleLogin">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Profil</span>
</label>
<select
v-model="selectedProfileId"
class="select select-bordered w-full"
required
>
<span>{{ profile.firstName }} {{ profile.lastName }}</span>
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
</button>
<option value="" disabled>
Choisir un profil...
</option>
<option
v-for="profile in profiles"
:key="profile.id"
:value="profile.id"
>
{{ profile.firstName }} {{ profile.lastName }}
</option>
</select>
</div>
<p v-else class="text-sm text-base-content/60">
Aucun profil enregistré.
</p>
</section>
<footer v-if="activeProfile" class="mt-6 flex justify-between items-center">
<div class="form-control mb-2">
<label class="label">
<span class="label-text">Mot de passe</span>
</label>
<input
ref="passwordInput"
v-model="password"
type="password"
placeholder="Mot de passe"
class="input input-bordered w-full"
:class="{ 'input-error': loginError }"
>
</div>
<p v-if="loginError" class="text-error text-sm mb-4">
{{ loginError }}
</p>
<button
type="submit"
class="btn btn-primary w-full mt-4"
:disabled="!selectedProfileId || submitting"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
Se connecter
</button>
</form>
<footer v-if="activeProfile" class="mt-6 pt-4 border-t border-base-300 flex justify-between items-center">
<div class="text-sm text-base-content/70">
Profil actuel :
Connecte :
<span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span>
</div>
<button type="button" class="btn btn-outline btn-sm" @click="handleLogout">
connexion
Deconnexion
</button>
</footer>
</div>
@@ -59,32 +78,43 @@
</template>
<script setup>
import { onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useProfiles, useProfileSession } from '#imports'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
const router = useRouter()
const { profiles, loadingProfiles, fetchProfiles } = useProfiles()
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
const refreshProfiles = async () => {
await fetchProfiles()
}
const selectedProfileId = ref('')
const password = ref('')
const loginError = ref('')
const submitting = ref(false)
const passwordInput = ref(null)
const selectProfile = async (profileId) => {
const handleLogin = async () => {
if (!selectedProfileId.value) { return }
submitting.value = true
loginError.value = ''
try {
await activateProfile(profileId)
await fetchProfiles()
await activateProfile(selectedProfileId.value, password.value || undefined)
await router.push('/')
} catch (error) {
console.error('Erreur lors de la sélection du profil', error)
const err = error
if (err?.status === 401 || err?.statusCode === 401) {
loginError.value = 'Mot de passe incorrect.'
} else {
loginError.value = 'Erreur lors de la connexion.'
}
} finally {
submitting.value = false
}
}
const handleLogout = async () => {
await logout()
await router.push('/profiles')
selectedProfileId.value = ''
password.value = ''
}
onMounted(async () => {

View File

@@ -1,196 +0,0 @@
<template>
<main class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">
Gestion des profils
</h1>
<p class="text-sm text-base-content/70">
Sélectionnez, créez ou supprimez des profils.
</p>
</div>
<NuxtLink to="/" class="btn btn-ghost btn-sm">
Retour
</NuxtLink>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<section class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<header class="flex items-center justify-between">
<h2 class="card-title text-lg">
Profils existants
</h2>
<button type="button" class="btn btn-ghost btn-xs" :disabled="loadingProfiles" @click="refresh">
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs" />
<span v-else>Rafraîchir</span>
</button>
</header>
<div v-if="profiles.length" class="space-y-2 max-h-80 overflow-y-auto">
<div
v-for="profile in profiles"
:key="profile.id"
class="flex items-center justify-between rounded-lg border border-base-200 bg-base-100 px-3 py-2"
>
<div>
<p class="font-medium">
{{ profile.firstName }} {{ profile.lastName }}
</p>
<p class="text-xs text-base-content/60">
ID : {{ profile.id }}
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-sm"
:class="profile.id === activeProfile?.id ? 'btn-primary' : 'btn-outline'"
@click="select(profile.id)"
>
{{ profile.id === activeProfile?.id ? 'Actif' : 'Activer' }}
</button>
<button
type="button"
class="btn btn-error btn-sm"
@click="remove(profile.id)"
>
<span v-if="deleting === profile.id" class="loading loading-spinner loading-xs" />
<span v-else>Supprimer</span>
</button>
</div>
</div>
</div>
<p v-else class="text-sm text-base-content/60">
Aucun profil enregistré.
</p>
</div>
</section>
<section class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<h2 class="card-title text-lg">
Créer un profil
</h2>
<form class="space-y-3" @submit.prevent="create">
<div class="form-control">
<label class="label"><span class="label-text">Prénom</span></label>
<input
v-model="createForm.firstName"
type="text"
class="input input-bordered"
placeholder="Prénom"
required
>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Nom</span></label>
<input
v-model="createForm.lastName"
type="text"
class="input input-bordered"
placeholder="Nom"
required
>
</div>
<button type="submit" class="btn btn-primary w-full" :disabled="creating">
<span v-if="creating" class="loading loading-spinner loading-sm" />
<span v-else>Créer et activer</span>
</button>
</form>
</div>
</section>
</div>
<div v-if="activeProfile" class="mt-8 flex items-center justify-between bg-base-100 shadow-lg rounded-lg p-4">
<div>
<p class="text-sm text-base-content/70">
Profil actif :
</p>
<p class="font-semibold text-base-content">
{{ activeProfile.firstName }} {{ activeProfile.lastName }}
</p>
</div>
<button type="button" class="btn btn-outline" @click="handleLogout">
Déconnexion
</button>
</div>
</div>
</main>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useProfiles, useProfileSession } from '#imports'
const router = useRouter()
const { profiles, loadingProfiles, fetchProfiles, createProfile, deleteProfile } = useProfiles()
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
const createForm = reactive({
firstName: '',
lastName: ''
})
const creating = ref(false)
const deleting = ref(null)
const refresh = async () => {
await fetchProfiles()
await fetchCurrentProfile()
}
const select = async (profileId) => {
try {
await activateProfile(profileId)
await refresh()
} catch (error) {
console.error('Erreur lors de la sélection du profil', error)
}
}
const create = async () => {
creating.value = true
try {
const profile = await createProfile({
firstName: createForm.firstName,
lastName: createForm.lastName
})
createForm.firstName = ''
createForm.lastName = ''
await activateProfile(profile.id)
await refresh()
} catch (error) {
console.error('Erreur lors de la création du profil', error)
} finally {
creating.value = false
}
}
const { confirm } = useConfirm()
const remove = async (profileId) => {
if (!await confirm({ message: 'Supprimer ce profil ?' })) { return }
deleting.value = profileId
try {
await deleteProfile(profileId)
await refresh()
} catch (error) {
console.error('Erreur lors de la suppression du profil', error)
} finally {
deleting.value = null
}
}
const handleLogout = async () => {
await logout()
await refresh()
await router.push('/profiles')
}
onMounted(async () => {
await refresh()
})
</script>

View File

@@ -11,7 +11,7 @@
<h2 class="text-2xl font-bold">
Sites
</h2>
<button class="btn btn-primary" @click="openCreateSiteModal">
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
Ajouter un site
</button>
@@ -30,7 +30,7 @@
<p class="text-gray-500 mb-4">
Commencez par ajouter votre premier site.
</p>
<button class="btn btn-primary" @click="openCreateSiteModal">
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
Ajouter un site
</button>
</div>
@@ -50,6 +50,7 @@
<SiteCreateModal
:visible="showAddSiteModal"
:site="newSite"
:disabled="!canEdit"
@close="closeCreateModal"
@submit="handleCreateSite"
/>
@@ -64,6 +65,7 @@
:can-preview-document="canPreviewDocument"
:document-icon="documentIcon"
:format-size="formatSize"
:disabled="!canEdit"
@close="closeEditModal"
@submit="handleUpdateSite"
@remove-document="handleRemoveSiteDocument"
@@ -83,6 +85,8 @@ import SiteCreateModal from '~/components/sites/SiteCreateModal.vue'
import SiteEditModal from '~/components/sites/SiteEditModal.vue'
import { useSiteManagement } from '~/composables/useSiteManagement'
const { canEdit } = usePermissions()
const {
sites,
loading,

View File

@@ -153,6 +153,7 @@ import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import IconLucideSquarePen from '~icons/lucide/square-pen'
const { canEdit } = usePermissions()
const route = useRoute()
const { getMachineTypeById } = useMachineTypesApi()
const { showError } = useToast()

View File

@@ -54,6 +54,7 @@ import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { getMachineTypeById, updateMachineType } = useMachineTypesApi()
@@ -204,6 +205,10 @@ const saveChanges = async () => {
// Charger le type au montage
onMounted(async () => {
if (!canEdit.value) {
router.replace(`/type/${route.params.id}`)
return
}
try {
const typeId = route.params.id
const result = await getMachineTypeById(typeId, true)