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

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