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