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

@@ -186,9 +186,9 @@
Connecté en tant que<br />
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
</li>
<li>
<NuxtLink to="/profiles/manage" class="justify-between">
Gestion des profils
<li v-if="isAdmin">
<NuxtLink to="/admin" class="justify-between">
Administration
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
</NuxtLink>
</li>
@@ -214,6 +214,7 @@
import { computed } from 'vue'
import { useRoute } from '#imports'
import { useNavDropdown } from '~/composables/useNavDropdown'
import { usePermissions } from '~/composables/usePermissions'
import { useProfileSession } from '~/composables/useProfileSession'
import IconLucideMenu from '~icons/lucide/menu'
import IconLucideSettings from '~icons/lucide/settings'
@@ -288,6 +289,7 @@ const navGroups: NavGroup[] = [
const route = useRoute()
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
const { activeProfile } = useProfileSession()
const { isAdmin } = usePermissions()
const isActive = (path: string) => {
if (path === '/') {

View File

@@ -60,6 +60,8 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer'
const { canEdit } = usePermissions()
defineProps<{
title: string
isDetailsView: boolean

View File

@@ -16,6 +16,7 @@
:dir="dir"
:loading="loading"
:show-category-tabs="allowCategorySwitch"
:can-edit="canEdit"
@update:category="onCategoryChange"
@update:search="onSearchInput"
@update:sort="onSortChange"
@@ -30,6 +31,7 @@
:limit="limit"
:offset="offset"
:category="selectedCategory"
:can-edit="canEdit"
@related="openRelatedModal"
@edit="openEditPage"
@delete="confirmDelete"
@@ -169,6 +171,7 @@ let activeController: AbortController | null = null;
const router = useRouter();
const { showError, showSuccess } = useToast();
const { get } = useApi();
const { canEdit } = usePermissions();
const headingText = computed(() => props.heading);
const descriptionText = computed(

View File

@@ -29,7 +29,7 @@
class="select select-bordered w-full"
name="category"
required
:disabled="lockCategory"
:disabled="lockCategory || isReadonly"
>
<option value="COMPONENT">Composants</option>
<option value="PIECE">Pièces</option>
@@ -134,7 +134,7 @@
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
<button v-if="!isReadonly" type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
{{ submitLabel }}
</button>
@@ -176,6 +176,7 @@ const props = withDefaults(defineProps<{
disableSubmitMessage?: string
restrictedMode?: boolean
restrictedModeMessage?: string
readonly?: boolean
}>(), {
initialData: null,
saving: false,
@@ -187,6 +188,7 @@ const props = withDefaults(defineProps<{
disableSubmitMessage: '',
restrictedMode: false,
restrictedModeMessage: '',
readonly: false,
})
const emit = defineEmits<{
@@ -209,7 +211,8 @@ const disableSubmitMessage = computed(() =>
? props.disableSubmitMessage
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
)
const restrictedMode = computed(() => props.restrictedMode === true)
const isReadonly = computed(() => props.readonly === true)
const restrictedMode = computed(() => props.restrictedMode === true || isReadonly.value)
const restrictedModeMessage = computed(() =>
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
? props.restrictedModeMessage
@@ -291,7 +294,7 @@ const resetForm = () => {
}
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value || isReadonly.value)
const validate = () => {
errors.name = undefined
@@ -308,6 +311,7 @@ const validate = () => {
}
const handleSubmit = () => {
if (isReadonly.value) return
if (!validate()) {
return
}

View File

@@ -49,7 +49,7 @@
Liés
</button>
<button
v-if="showConvertButton"
v-if="canEdit && showConvertButton"
type="button"
class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)"
@@ -60,7 +60,7 @@
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer
</button>
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
Supprimer
</button>
</td>
@@ -88,7 +88,7 @@
Liés
</button>
<button
v-if="showConvertButton"
v-if="canEdit && showConvertButton"
type="button"
class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)"
@@ -99,7 +99,7 @@
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer
</button>
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
Supprimer
</button>
</footer>
@@ -146,6 +146,7 @@ const props = defineProps<{
limit: number;
offset: number;
category?: ModelCategory;
canEdit?: boolean;
}>();
const emit = defineEmits<{

View File

@@ -83,13 +83,14 @@ import type { ModelCategory } from '~/services/modelTypes';
type SortField = 'name' | 'createdAt';
type SortDirection = 'asc' | 'desc';
const props = defineProps<{
const props = defineProps<{
category: ModelCategory;
search: string;
sort: SortField;
dir: SortDirection;
loading?: boolean;
showCategoryTabs?: boolean;
canEdit?: boolean;
}>();
const emit = defineEmits<{

View File

@@ -37,9 +37,9 @@
<div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
Modifier
{{ canEdit ? 'Modifier' : 'Consulter' }}
</button>
<button class="btn btn-sm btn-error" @click="emit('delete', site)">
<button v-if="canEdit" class="btn btn-sm btn-error" @click="emit('delete', site)">
Supprimer
</button>
</div>
@@ -55,6 +55,8 @@ import IconLucidePhone from '~icons/lucide/phone'
import IconLucideUser from '~icons/lucide/user'
import { formatPhone } from '~/utils/formatters/phone'
const { canEdit } = usePermissions()
const props = defineProps({
site: {
type: Object,

View File

@@ -9,11 +9,12 @@
type="text"
placeholder="Nom et prénom"
class="input input-bordered"
:disabled="disabled"
required
/>
</div>
<FieldPhone v-model="contactPhone" required />
<FieldPhone v-model="contactPhone" :disabled="disabled" required />
<div class="form-control">
<label class="label">
@@ -24,6 +25,7 @@
type="text"
placeholder="Adresse complète"
class="input input-bordered"
:disabled="disabled"
required
/>
</div>
@@ -38,6 +40,7 @@
type="text"
placeholder="Code postal"
class="input input-bordered"
:disabled="disabled"
required
/>
</div>
@@ -51,6 +54,7 @@
type="text"
placeholder="Ville"
class="input input-bordered"
:disabled="disabled"
required
/>
</div>
@@ -77,6 +81,10 @@ const props = defineProps({
type: Object as PropType<SiteForm>,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
})
const form = toRef(props, 'form')

View File

@@ -12,17 +12,18 @@
type="text"
placeholder="Ex: Usine principale"
class="input input-bordered"
:disabled="disabled"
required
/>
</div>
<SiteContactFormFields :form="siteRef" />
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
<div class="modal-action">
<button type="button" class="btn" @click="emit('close')">
Annuler
</button>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" :disabled="disabled">
Créer le site
</button>
</div>
@@ -53,6 +54,10 @@ const props = defineProps({
site: {
type: Object as PropType<SiteForm>,
required: true
},
disabled: {
type: Boolean,
default: false
}
})

View File

@@ -2,7 +2,7 @@
<div v-if="visible" class="modal modal-open">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-4">
Modifier le site
{{ disabled ? 'Détails du site' : 'Modifier le site' }}
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span>
</h3>
<form class="space-y-4" @submit.prevent="emit('submit')">
@@ -15,11 +15,12 @@
type="text"
placeholder="Nom du site"
class="input input-bordered"
:disabled="disabled"
required
>
</div>
<SiteContactFormFields :form="props.form" />
<SiteContactFormFields :form="props.form" :disabled="disabled" />
<div class="border-t border-base-200 pt-4 space-y-4">
<div class="flex items-center justify-between">
@@ -37,6 +38,7 @@
</div>
<DocumentUpload
v-if="!disabled"
v-model="selectedFilesModel"
title="Déposer vos fichiers"
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
@@ -90,7 +92,7 @@
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
Télécharger
</button>
<button type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
<button v-if="!disabled" type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
Supprimer
</button>
</div>
@@ -103,7 +105,7 @@
<button type="button" class="btn" @click="emit('close')">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="uploadingDocuments">
<button type="submit" class="btn btn-primary" :disabled="disabled || uploadingDocuments">
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
Enregistrer
</button>
@@ -155,6 +157,10 @@ const props = defineProps({
formatSize: {
type: Function,
required: true
},
disabled: {
type: Boolean,
default: false
}
})