refactor(directory) : gate report actions via RBAC permissions + guard report deletion
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m0s

- replace hardcoded ROLE_ADMIN check with usePermissions().can('directory.{clients,prospects}.manage')
- rename misleading isAdmin prop to canManage in CommercialReportTab and ReportDocumentList
- add busy guard on delete confirmation modal to prevent duplicate DELETE on double-click
This commit is contained in:
Matthieu
2026-06-24 10:06:25 +02:00
parent 80b2fa5ce6
commit 0f14f26fd3
5 changed files with 33 additions and 21 deletions
@@ -13,12 +13,14 @@
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
:disabled="busy"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
:disabled="busy"
@click="$emit('confirm')"
/>
</div>
@@ -29,8 +31,10 @@
</template>
<script setup lang="ts">
defineProps<{
const props = defineProps<{
modelValue: boolean
// Suppression en cours : on désactive les actions pour éviter un double envoi.
busy?: boolean
}>()
const emit = defineEmits<{
@@ -39,6 +43,7 @@ const emit = defineEmits<{
}>()
function cancel() {
if (props.busy) return
emit('update:modelValue', false)
}
</script>
@@ -6,7 +6,7 @@
<span v-if="reports.length">{{ $t('directory.reports.count', { n: reports.length }, reports.length) }}</span>
</p>
<MalioButton
v-if="isAdmin"
v-if="canManage"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -24,7 +24,7 @@
<p class="font-medium text-neutral-600">{{ $t('directory.reports.empty') }}</p>
<p class="max-w-sm text-sm text-neutral-400">{{ $t('directory.reports.emptyHint') }}</p>
<MalioButton
v-if="isAdmin"
v-if="canManage"
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
@@ -70,7 +70,7 @@
<span v-if="report.author"> · {{ report.author.username }}</span>
</p>
</div>
<div v-if="isAdmin" class="flex shrink-0 gap-1">
<div v-if="canManage" class="flex shrink-0 gap-1">
<MalioButtonIcon
icon="mdi:pencil-outline"
variant="ghost"
@@ -97,7 +97,7 @@
<!-- Documents joints -->
<div
v-if="(report.documents?.length ?? 0) || isAdmin"
v-if="(report.documents?.length ?? 0) || canManage"
class="mt-3 border-t border-neutral-100 pt-3"
>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400">
@@ -107,11 +107,11 @@
<ReportDocumentList
v-if="report.documents?.length"
:documents="report.documents"
:is-admin="isAdmin"
:can-manage="canManage"
@delete="(docId) => removeDocument(docId)"
/>
<ReportDocumentUpload
v-if="isAdmin"
v-if="canManage"
:report-id="report.id"
@uploaded="reload"
/>
@@ -129,6 +129,7 @@
/>
<ConfirmDeleteReportModal
v-model="confirmOpen"
:busy="deleting"
@confirm="confirmDelete"
/>
</div>
@@ -141,7 +142,7 @@ import { useReportDocumentService } from '~/modules/directory/services/report-do
const props = defineProps<{
owner: { client?: string, prospect?: string }
isAdmin: boolean
canManage: boolean
}>()
const reportService = useCommercialReportService()
@@ -155,6 +156,7 @@ const editing = ref<CommercialReport | null>(null)
const confirmOpen = ref(false)
const pendingDelete = ref<CommercialReport | null>(null)
const deleting = ref(false)
// Le plus récent en haut (l'API ne garantit pas l'ordre).
const sortedReports = computed(() =>
@@ -208,11 +210,16 @@ function askDelete(report: CommercialReport): void {
}
async function confirmDelete(): Promise<void> {
if (!pendingDelete.value) return
await reportService.remove(pendingDelete.value.id)
confirmOpen.value = false
pendingDelete.value = null
await reload()
if (!pendingDelete.value || deleting.value) return
deleting.value = true
try {
await reportService.remove(pendingDelete.value.id)
confirmOpen.value = false
pendingDelete.value = null
await reload()
} finally {
deleting.value = false
}
}
async function removeDocument(id: number): Promise<void> {
@@ -15,7 +15,7 @@
{{ doc.originalName }}
</a>
<MalioButtonIcon
v-if="isAdmin"
v-if="canManage"
icon="mdi:trash-can-outline"
button-class="!text-red-600"
:aria-label="$t('common.delete')"
@@ -32,7 +32,7 @@
import type { ReportDocument } from '~/modules/directory/services/dto/report-document'
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
defineProps<{ documents: ReportDocument[], isAdmin: boolean }>()
defineProps<{ documents: ReportDocument[], canManage: boolean }>()
defineEmits<{ delete: [id: number] }>()
const { getDownloadUrl } = useReportDocumentService()
@@ -99,7 +99,7 @@
</template>
<template #report>
<CommercialReportTab :owner="owner" :is-admin="isAdmin" />
<CommercialReportTab :owner="owner" :can-manage="canManage" />
</template>
</MalioTabList>
</template>
@@ -137,8 +137,8 @@ const {
load,
} = useDirectoryDetail(owner)
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
const { can } = usePermissions()
const canManage = computed(() => can('directory.clients.manage'))
const client = ref<Client | null>(null)
const loading = ref(true)
@@ -119,7 +119,7 @@
</template>
<template #report>
<CommercialReportTab :owner="owner" :is-admin="isAdmin" />
<CommercialReportTab :owner="owner" :can-manage="canManage" />
</template>
</MalioTabList>
</template>
@@ -157,8 +157,8 @@ const {
load,
} = useDirectoryDetail(owner)
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
const { can } = usePermissions()
const canManage = computed(() => can('directory.prospects.manage'))
const prospect = ref<Prospect | null>(null)
const loading = ref(true)