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:
@@ -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 === '/') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user