+
(null)
const showMailOptions = computed(() => {
- return isMeAdmin() && props.user.id !== me.value.id && props.user.email
+ return isAdmin && props.user.id !== me.value.id && props.user.email
})
const mailOptions = computed(() => [
@@ -149,7 +150,7 @@ async function deleteUser({ spam = false }) {
params.set('no_mail', 'true')
params.set('delete_comments', 'true')
}
- if (isMeAdmin() && mailOption.value === 'auto' && !spam) {
+ if (isAdmin && mailOption.value === 'auto' && !spam) {
params.set('send_legal_notice', 'true')
}
@@ -170,7 +171,7 @@ async function deleteUser({ spam = false }) {
await navigateTo(`${config.public.apiBase}/logout`, { external: true })
}
}
- else if (isMeAdmin() && mailOption.value === 'custom' && props.user.email) {
+ else if (isAdmin && mailOption.value === 'custom' && props.user.email) {
deleted.value = true
}
else {
diff --git a/tests/admin/delete-with-legal-mail.spec.ts b/tests/admin/delete-with-legal-mail.spec.ts
index 5b765c6f..fc449501 100644
--- a/tests/admin/delete-with-legal-mail.spec.ts
+++ b/tests/admin/delete-with-legal-mail.spec.ts
@@ -1,91 +1,94 @@
import { test, expect } from '@playwright/test'
-import * as path from 'path'
-import { clickOutside } from '../helpers'
-const __dirname = import.meta.dirname
+const API_BASE = process.env.NUXT_PUBLIC_API_BASE || 'http://dev.local:7000'
test.describe('Delete modal with legal mail option', () => {
- test('shows mail option for admin and sends legal notice on delete', async ({ page }) => {
- const uniqueId = Date.now()
-
- // Create a reuse
- await page.goto('/')
- await page.getByRole('button', { name: 'Publier sur data.gouv.fr' }).click()
- await page.getByRole('link', { name: 'Une réutilisation' }).click()
- await page.waitForURL('**/admin/reuses/new**')
-
- // Select producer
- await page.getByTestId('producer-select').click()
- await page.getByRole('option', { name: 'Admin User' }).click()
-
- // Fill the form
- await page.getByRole('textbox', { name: 'Nom de la réutilisation' }).fill(`Test suppression ${uniqueId}`)
- await page.getByRole('textbox', { name: 'Lien' }).fill(`https://example.com/test-delete-${uniqueId}`)
-
- // Select type
- await page.getByTestId('searchable-select-type').click()
- await page.getByRole('option', { name: 'Application' }).click()
- await clickOutside(page)
-
- // Select thématique
- await page.getByTestId('searchable-select-thmatique').click()
- await page.getByRole('option', { name: 'Culture et loisirs' }).click()
- await clickOutside(page)
-
- // Fill description
- await page.getByTestId('markdown-editor').click()
- await page.getByTestId('markdown-editor').fill('Une description de ma réutilisation qui utilise des données ouvertes. Cette réutilisation permet de visualiser et d\'analyser des données publiques de manière innovante et accessible à tous les citoyens.')
- await page.getByTestId('markdown-editor').press('Tab')
-
- // Upload image
- const fileChooserPromise = page.waitForEvent('filechooser')
- await page.getByRole('button', { name: 'Parcourir' }).click()
- const fileChooser = await fileChooserPromise
- await fileChooser.setFiles(path.join(__dirname, '../../public/nuxt_images/onboarding/logo-ign.png'))
- await clickOutside(page)
- await page.waitForTimeout(500)
-
- // Go to step 2
- await page.getByRole('button', { name: 'Suivant' }).click()
- await expect(page.getByText('Étape 2 sur 3')).toBeVisible()
-
- // Go to step 3
- await page.getByRole('button', { name: 'Suivant' }).click()
- await expect(page.getByText('Étape 3 sur 3')).toBeVisible()
-
- // Publish the reuse
- await page.getByRole('button', { name: 'Publier la réutilisation' }).click()
- await expect(page.getByRole('heading', { level: 1 })).toContainText(`Test suppression ${uniqueId}`)
-
- // Go to edit page
- await page.getByRole('link', { name: 'Modifier' }).click()
- await expect(page.getByRole('button', { name: 'Supprimer' })).toBeVisible()
-
- // Open delete modal
- await page.getByRole('button', { name: 'Supprimer' }).click()
- await expect(page.getByRole('dialog')).toBeVisible()
-
- const dialog = page.getByRole('dialog')
-
- // Verify mail options are shown for admin
- await expect(dialog.getByText('Notification par email')).toBeVisible()
- await expect(dialog.getByText('Envoyer un mail automatique (voies de recours)')).toBeVisible()
-
- // Delete button should be disabled until an option is selected
- const deleteButton = dialog.getByRole('button', { name: 'Supprimer cette réutilisation' })
- await expect(deleteButton).toBeDisabled()
-
- // Select automatic mail option
- await dialog.getByText('Envoyer un mail automatique (voies de recours)').click()
-
- // Now delete button should be enabled
- await expect(deleteButton).toBeEnabled()
-
- // Confirm deletion
- await deleteButton.click()
-
- // Modal should close and reuse should be marked as deleted
- await expect(page.getByRole('dialog')).not.toBeVisible()
- await expect(page.getByText('Restaurer la réutilisation')).toBeVisible()
+ test.describe('Dataset deletion', () => {
+ test('shows mail options and sends automatic legal notice', async ({ page }) => {
+ const uniqueId = Date.now()
+
+ // Create dataset via API
+ const response = await page.request.post(`${API_BASE}/api/1/datasets/`, {
+ data: {
+ title: `Test delete auto mail ${uniqueId}`,
+ description: 'Description pour test de suppression',
+ frequency: 'unknown',
+ },
+ })
+ const dataset = await response.json()
+
+ // Go to admin page
+ await page.goto(`/admin/datasets/${dataset.id}/`)
+ await expect(page.getByRole('button', { name: 'Supprimer' })).toBeVisible()
+
+ // Open delete modal
+ await page.getByRole('button', { name: 'Supprimer' }).click()
+ await expect(page.getByRole('dialog')).toBeVisible()
+
+ const dialog = page.getByRole('dialog')
+
+ // Verify mail options are shown for admin
+ await expect(dialog.getByText('Notification par email')).toBeVisible()
+ await expect(dialog.getByText('Envoyer un mail automatique (voies de recours)')).toBeVisible()
+ await expect(dialog.getByText('Envoyer un mail personnalisé')).toBeVisible()
+
+ // Delete button should be disabled until an option is selected
+ const deleteButton = dialog.getByRole('button', { name: 'Supprimer le jeu de données' })
+ await expect(deleteButton).toBeDisabled()
+
+ // Select automatic mail option
+ await dialog.getByText('Envoyer un mail automatique (voies de recours)').click()
+ await expect(deleteButton).toBeEnabled()
+
+ // Confirm deletion
+ await deleteButton.click()
+
+ // Modal should close and dataset should be marked as deleted
+ await expect(page.getByRole('dialog')).not.toBeVisible()
+ await expect(page.getByText('Restaurer ce jeu de données')).toBeVisible()
+ })
+
+ test('shows mailto link when custom mail option is selected', async ({ page }) => {
+ const uniqueId = Date.now()
+
+ // Create dataset via API
+ const response = await page.request.post(`${API_BASE}/api/1/datasets/`, {
+ data: {
+ title: `Test delete custom mail ${uniqueId}`,
+ description: 'Description pour test de suppression',
+ frequency: 'unknown',
+ },
+ })
+ const dataset = await response.json()
+
+ // Go to admin page
+ await page.goto(`/admin/datasets/${dataset.id}/`)
+ await expect(page.getByRole('button', { name: 'Supprimer' })).toBeVisible()
+
+ // Open delete modal
+ await page.getByRole('button', { name: 'Supprimer' }).click()
+ await expect(page.getByRole('dialog')).toBeVisible()
+
+ const dialog = page.getByRole('dialog')
+
+ // Select custom mail option
+ await dialog.getByText('Envoyer un mail personnalisé').click()
+
+ // Confirm deletion
+ await dialog.getByRole('button', { name: 'Supprimer le jeu de données' }).click()
+
+ // Modal should stay open with mailto link
+ await expect(dialog.getByText('Suppression effectuée')).toBeVisible()
+ await expect(dialog.getByText('Vous pouvez maintenant envoyer un mail personnalisé au propriétaire.')).toBeVisible()
+ await expect(dialog.getByRole('link', { name: 'Envoyer le mail personnalisé' })).toBeVisible()
+
+ // Verify mailto link format
+ const mailtoLink = dialog.getByRole('link', { name: 'Envoyer le mail personnalisé' })
+ await expect(mailtoLink).toHaveAttribute('href', /^mailto:/)
+
+ // Close the modal (use the footer button, not the X button)
+ await dialog.getByRole('button', { name: 'Fermer', exact: true }).last().click()
+ await expect(page.getByRole('dialog')).not.toBeVisible()
+ })
})
})
diff --git a/utils/owner.ts b/utils/owner.ts
index fae90945..4ced2d79 100644
--- a/utils/owner.ts
+++ b/utils/owner.ts
@@ -1,11 +1,11 @@
import { throwOnNever, type Dataservice, type DatasetV2, type DatasetV2WithFullObject, type Organization, type Reuse, type User } from '@datagouv/components-next'
import type { $Fetch } from 'nitropack'
-import type { Thread } from '~/types/discussions'
+import type { Comment, Thread } from '~/types/discussions'
-export type OwnedObject = DatasetV2 | DatasetV2WithFullObject | Reuse | Dataservice | Organization | User | Thread
+export type OwnedObject = DatasetV2 | DatasetV2WithFullObject | Reuse | Dataservice | Organization | User | Thread | Comment
type OrganizationWithMembers = Organization & {
- members: Array<{ user: User & { email?: string }, role: string }>
+ members: Array<{ user: User, role: string }>
}
export async function getOwnerEmails($api: $Fetch, obj: OwnedObject): Promise {
@@ -19,15 +19,16 @@ export async function getOwnerEmails($api: $Fetch, obj: OwnedObject): Promise(`/api/1/users/${obj.owner.id}/`)
+ const user = await $api(`/api/1/users/${obj.owner.id}/`)
return user.email ? [user.email] : []
}
if (obj.organization) {
@@ -50,5 +51,5 @@ export async function getOrganizationAdminEmails($api: $Fetch, orgId: string): P
export function getUserEmail(user: User | null | undefined): string | null {
if (!user) return null
- return 'email' in user ? (user as User & { email?: string }).email || null : null
+ return user.email || null
}
From f60f750a55f1d4239f24a5e48b06a9f17c1cf1db Mon Sep 17 00:00:00 2001
From: Thibaud Dauce
Date: Mon, 5 Jan 2026 14:13:19 +0100
Subject: [PATCH 07/11] More simplication and remove duplicate/dead code
---
components/Admin/AdminDeleteModal.vue | 11 ++---------
components/User/DeleteUserModal.vue | 10 ++--------
composables/useDeleteMailto.ts | 9 +++++++--
pages/admin/site/moderation.vue | 3 +--
types/delete.ts | 2 ++
utils/owner.ts | 5 -----
6 files changed, 14 insertions(+), 26 deletions(-)
create mode 100644 types/delete.ts
diff --git a/components/Admin/AdminDeleteModal.vue b/components/Admin/AdminDeleteModal.vue
index 7a391d3a..1fe0a1cd 100644
--- a/components/Admin/AdminDeleteModal.vue
+++ b/components/Admin/AdminDeleteModal.vue
@@ -83,9 +83,7 @@ import { isMeAdmin } from '~/utils/auth'
import { useDeleteMailto } from '~/composables/useDeleteMailto'
import { getOwnerEmails, type OwnedObject } from '~/utils/owner'
import RadioButtons from '~/components/RadioButtons.vue'
-
-type MailOption = 'auto' | 'custom'
-type ObjectType = 'dataset' | 'reuse' | 'dataservice' | 'organization' | 'user' | 'discussion' | 'comment'
+import type { ObjectType, MailOption } from '~/types/delete'
const props = withDefaults(defineProps<{
title: string
@@ -104,7 +102,7 @@ const emit = defineEmits<{
const { t } = useTranslation()
const { $api } = useNuxtApp()
-const { generateMailtoLink } = useDeleteMailto()
+const { generateMailtoLink, mailOptions } = useDeleteMailto()
const isAdmin = isMeAdmin()
const isOpen = ref(false)
@@ -115,11 +113,6 @@ const recipientEmails = ref([])
const showMailOptions = computed(() => isAdmin && recipientEmails.value.length > 0)
-const mailOptions = computed(() => [
- { value: 'auto' as const, label: t('Envoyer un mail automatique (voies de recours)') },
- { value: 'custom' as const, label: t('Envoyer un mail personnalisé') },
-])
-
const mailtoLink = computed(() => {
return generateMailtoLink(recipientEmails.value, props.objectType, props.objectTitle)
})
diff --git a/components/User/DeleteUserModal.vue b/components/User/DeleteUserModal.vue
index a54b547e..44d9c2fd 100644
--- a/components/User/DeleteUserModal.vue
+++ b/components/User/DeleteUserModal.vue
@@ -98,8 +98,7 @@ import { BrandedButton, type User, toast } from '@datagouv/components-next'
import { isMeAdmin, useLogout } from '~/utils/auth'
import { useDeleteMailto } from '~/composables/useDeleteMailto'
import RadioButtons from '~/components/RadioButtons.vue'
-
-type MailOption = 'auto' | 'custom'
+import type { MailOption } from '~/types/delete'
const props = defineProps<{
user: User & { email?: string }
@@ -109,7 +108,7 @@ const me = useMe()
const { $api } = useNuxtApp()
const config = useRuntimeConfig()
const { t } = useTranslation()
-const { generateMailtoLink } = useDeleteMailto()
+const { generateMailtoLink, mailOptions } = useDeleteMailto()
const isAdmin = isMeAdmin()
const isOpen = ref(false)
@@ -121,11 +120,6 @@ const showMailOptions = computed(() => {
return isAdmin && props.user.id !== me.value.id && props.user.email
})
-const mailOptions = computed(() => [
- { value: 'auto' as MailOption, label: t('Envoyer un mail automatique (voies de recours)') },
- { value: 'custom' as MailOption, label: t('Envoyer un mail personnalisé') },
-])
-
const mailtoLink = computed(() => {
if (!props.user.email) return ''
return generateMailtoLink([props.user.email], 'user', props.user.first_name + ' ' + props.user.last_name)
diff --git a/composables/useDeleteMailto.ts b/composables/useDeleteMailto.ts
index 183625d2..00f0bda5 100644
--- a/composables/useDeleteMailto.ts
+++ b/composables/useDeleteMailto.ts
@@ -1,4 +1,4 @@
-type ObjectType = 'dataset' | 'reuse' | 'dataservice' | 'organization' | 'user' | 'discussion' | 'comment'
+import type { ObjectType, MailOption } from '~/types/delete'
export function useDeleteMailto() {
const { t } = useTranslation()
@@ -17,6 +17,11 @@ export function useDeleteMailto() {
return labels[type]
}
+ const mailOptions = computed(() => [
+ { value: 'auto' as MailOption, label: t('Envoyer un mail automatique (voies de recours)') },
+ { value: 'custom' as MailOption, label: t('Envoyer un mail personnalisé') },
+ ])
+
const generateMailtoLink = (
recipientEmails: string[],
objectType: ObjectType,
@@ -51,6 +56,6 @@ L'équipe {site}`, {
return {
generateMailtoLink,
- getObjectTypeLabel,
+ mailOptions,
}
}
diff --git a/pages/admin/site/moderation.vue b/pages/admin/site/moderation.vue
index f5fa361b..85367b49 100644
--- a/pages/admin/site/moderation.vue
+++ b/pages/admin/site/moderation.vue
@@ -260,6 +260,7 @@ import { AvatarWithName, LoadingBlock, Pagination, useFormatDate, BrandedButton
import { computed, ref } from 'vue'
import { RiCheckLine, RiDeleteBinLine, RiEyeOffLine } from '@remixicon/vue'
import type { PaginatedArray } from '~/types/types'
+import type { ObjectType } from '~/types/delete'
import AdminBreadcrumb from '~/components/Breadcrumbs/AdminBreadcrumb.vue'
import BreadcrumbItem from '~/components/Breadcrumbs/BreadcrumbItem.vue'
import AdminTable from '~/components/AdminTable/Table/AdminTable.vue'
@@ -389,8 +390,6 @@ const isSubjectDeleted = (subject: RecordSubjectFullObject['value']) => {
return false
}
-type ObjectType = 'dataset' | 'reuse' | 'dataservice' | 'organization' | 'user' | 'discussion' | 'comment'
-
const getObjectType = (subjectClass: string): ObjectType => {
return {
Dataset: 'dataset' as ObjectType,
diff --git a/types/delete.ts b/types/delete.ts
new file mode 100644
index 00000000..afa6274c
--- /dev/null
+++ b/types/delete.ts
@@ -0,0 +1,2 @@
+export type ObjectType = 'dataset' | 'reuse' | 'dataservice' | 'organization' | 'user' | 'discussion' | 'comment'
+export type MailOption = 'auto' | 'custom'
diff --git a/utils/owner.ts b/utils/owner.ts
index 4ced2d79..19932429 100644
--- a/utils/owner.ts
+++ b/utils/owner.ts
@@ -48,8 +48,3 @@ export async function getOrganizationAdminEmails($api: $Fetch, orgId: string): P
.filter((m): m is typeof m & { user: { email: string } } => m.role === 'admin' && !!m.user?.email)
.map(m => m.user.email)
}
-
-export function getUserEmail(user: User | null | undefined): string | null {
- if (!user) return null
- return user.email || null
-}
From e6aa293cf558716fdd9c2a9d30e6735518696814 Mon Sep 17 00:00:00 2001
From: Thibaud Dauce
Date: Mon, 5 Jan 2026 14:15:01 +0100
Subject: [PATCH 08/11] Rename OwnedObject to DeletableObject
---
components/Admin/AdminDeleteModal.vue | 4 ++--
utils/owner.ts | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/components/Admin/AdminDeleteModal.vue b/components/Admin/AdminDeleteModal.vue
index 1fe0a1cd..a754d10b 100644
--- a/components/Admin/AdminDeleteModal.vue
+++ b/components/Admin/AdminDeleteModal.vue
@@ -81,7 +81,7 @@ import { BrandedButton } from '@datagouv/components-next'
import { RiCheckLine } from '@remixicon/vue'
import { isMeAdmin } from '~/utils/auth'
import { useDeleteMailto } from '~/composables/useDeleteMailto'
-import { getOwnerEmails, type OwnedObject } from '~/utils/owner'
+import { getOwnerEmails, type DeletableObject } from '~/utils/owner'
import RadioButtons from '~/components/RadioButtons.vue'
import type { ObjectType, MailOption } from '~/types/delete'
@@ -89,7 +89,7 @@ const props = withDefaults(defineProps<{
title: string
deleteUrl: string
deleteButtonLabel?: string
- ownedObject?: OwnedObject | null
+ ownedObject?: DeletableObject | null
objectType: ObjectType
objectTitle?: string
}>(), {
diff --git a/utils/owner.ts b/utils/owner.ts
index 19932429..3db21d40 100644
--- a/utils/owner.ts
+++ b/utils/owner.ts
@@ -2,13 +2,13 @@ import { throwOnNever, type Dataservice, type DatasetV2, type DatasetV2WithFullO
import type { $Fetch } from 'nitropack'
import type { Comment, Thread } from '~/types/discussions'
-export type OwnedObject = DatasetV2 | DatasetV2WithFullObject | Reuse | Dataservice | Organization | User | Thread | Comment
+export type DeletableObject = DatasetV2 | DatasetV2WithFullObject | Reuse | Dataservice | Organization | User | Thread | Comment
type OrganizationWithMembers = Organization & {
members: Array<{ user: User, role: string }>
}
-export async function getOwnerEmails($api: $Fetch, obj: OwnedObject): Promise {
+export async function getOwnerEmails($api: $Fetch, obj: DeletableObject): Promise {
// User (has email directly)
if ('first_name' in obj && 'last_name' in obj) {
return obj.email ? [obj.email] : []
From 9bb670a2733138710bc24e5e72cfb495a0c3207f Mon Sep 17 00:00:00 2001
From: Thibaud Dauce
Date: Mon, 5 Jan 2026 14:20:32 +0100
Subject: [PATCH 09/11] Small tweaks
---
components/Admin/AdminDeleteModal.vue | 16 +++++++---------
.../Dataservices/AdminUpdateDataservicePage.vue | 2 +-
components/Datasets/AdminUpdateDatasetPage.vue | 2 +-
components/Discussions/DeleteCommentModal.vue | 2 +-
components/Discussions/DeleteThreadModal.vue | 2 +-
components/Reuses/AdminUpdateReusePage.vue | 2 +-
.../admin/organizations/[oid]/profile/index.vue | 2 +-
pages/admin/site/moderation.vue | 2 +-
types/delete.ts | 4 ++++
utils/owner.ts | 6 ++----
10 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/components/Admin/AdminDeleteModal.vue b/components/Admin/AdminDeleteModal.vue
index a754d10b..26192b1e 100644
--- a/components/Admin/AdminDeleteModal.vue
+++ b/components/Admin/AdminDeleteModal.vue
@@ -81,20 +81,18 @@ import { BrandedButton } from '@datagouv/components-next'
import { RiCheckLine } from '@remixicon/vue'
import { isMeAdmin } from '~/utils/auth'
import { useDeleteMailto } from '~/composables/useDeleteMailto'
-import { getOwnerEmails, type DeletableObject } from '~/utils/owner'
+import { getOwnerEmails } from '~/utils/owner'
import RadioButtons from '~/components/RadioButtons.vue'
-import type { ObjectType, MailOption } from '~/types/delete'
+import type { ObjectType, MailOption, DeletableObject } from '~/types/delete'
-const props = withDefaults(defineProps<{
+const props = defineProps<{
title: string
deleteUrl: string
deleteButtonLabel?: string
- ownedObject?: DeletableObject | null
+ deletableObject: DeletableObject
objectType: ObjectType
objectTitle?: string
-}>(), {
- ownedObject: null,
-})
+}>()
const emit = defineEmits<{
deleted: []
@@ -120,8 +118,8 @@ const mailtoLink = computed(() => {
const deleteButtonLabel = computed(() => props.deleteButtonLabel || t('Supprimer'))
watch(isOpen, async (newVal) => {
- if (newVal && isAdmin && props.ownedObject) {
- recipientEmails.value = await getOwnerEmails($api, props.ownedObject)
+ if (newVal && isAdmin) {
+ recipientEmails.value = await getOwnerEmails($api, props.deletableObject)
}
if (!newVal) {
deleted.value = false
diff --git a/components/Dataservices/AdminUpdateDataservicePage.vue b/components/Dataservices/AdminUpdateDataservicePage.vue
index 8f438246..80731636 100644
--- a/components/Dataservices/AdminUpdateDataservicePage.vue
+++ b/components/Dataservices/AdminUpdateDataservicePage.vue
@@ -105,7 +105,7 @@
:title="$t('Êtes-vous sûr de vouloir supprimer cette API ?')"
:delete-url="`/api/1/dataservices/${route.params.id}`"
:delete-button-label="$t(`Supprimer l'API`)"
- :owned-object="dataservice"
+ :deletable-object="dataservice"
object-type="dataservice"
:object-title="dataservice.title"
@deleted="onDataserviceDeleted"
diff --git a/components/Datasets/AdminUpdateDatasetPage.vue b/components/Datasets/AdminUpdateDatasetPage.vue
index d5f8a1c7..7e24cad8 100644
--- a/components/Datasets/AdminUpdateDatasetPage.vue
+++ b/components/Datasets/AdminUpdateDatasetPage.vue
@@ -99,7 +99,7 @@
:title="$t('Êtes-vous sûr de vouloir supprimer ce jeu de données ?')"
:delete-url="`/api/1/datasets/${route.params.id}`"
:delete-button-label="$t('Supprimer le jeu de données')"
- :owned-object="dataset"
+ :deletable-object="dataset"
object-type="dataset"
:object-title="dataset.title"
@deleted="onDatasetDeleted"
diff --git a/components/Discussions/DeleteCommentModal.vue b/components/Discussions/DeleteCommentModal.vue
index 4e0c4bda..6086f201 100644
--- a/components/Discussions/DeleteCommentModal.vue
+++ b/components/Discussions/DeleteCommentModal.vue
@@ -3,7 +3,7 @@
:title="t('Êtes-vous sûrs de vouloir supprimer ce message ?')"
:delete-url="`/api/1/discussions/${thread.id}/comments/${index}/`"
:delete-button-label="t('Supprimer le commentaire')"
- :owned-object="comment"
+ :deletable-object="comment"
object-type="comment"
@deleted="emit('deleted')"
>
diff --git a/components/Discussions/DeleteThreadModal.vue b/components/Discussions/DeleteThreadModal.vue
index 0f3378bc..f1167aa7 100644
--- a/components/Discussions/DeleteThreadModal.vue
+++ b/components/Discussions/DeleteThreadModal.vue
@@ -3,7 +3,7 @@
:title="t('Êtes-vous sûr de vouloir supprimer cette discussion ?')"
:delete-url="`/api/1/discussions/${thread.id}/`"
:delete-button-label="t('Supprimer la discussion et les commentaires')"
- :owned-object="thread"
+ :deletable-object="thread"
object-type="discussion"
:object-title="thread.title"
@deleted="emit('deleted')"
diff --git a/components/Reuses/AdminUpdateReusePage.vue b/components/Reuses/AdminUpdateReusePage.vue
index 07d09e89..2ebd3790 100644
--- a/components/Reuses/AdminUpdateReusePage.vue
+++ b/components/Reuses/AdminUpdateReusePage.vue
@@ -106,7 +106,7 @@
:title="$t('Êtes-vous sûr de vouloir supprimer cette réutilisation ?')"
:delete-url="`/api/1/reuses/${route.params.id}`"
:delete-button-label="$t('Supprimer cette réutilisation')"
- :owned-object="reuse"
+ :deletable-object="reuse"
object-type="reuse"
:object-title="reuse.title"
@deleted="onReuseDeleted"
diff --git a/pages/admin/organizations/[oid]/profile/index.vue b/pages/admin/organizations/[oid]/profile/index.vue
index a493aa75..347da04b 100644
--- a/pages/admin/organizations/[oid]/profile/index.vue
+++ b/pages/admin/organizations/[oid]/profile/index.vue
@@ -51,7 +51,7 @@
:title="t('Êtes-vous sûrs de vouloir supprimer cette organisation ?')"
:delete-url="`/api/1/organizations/${organization.id}/`"
:delete-button-label="t(`Supprimer l'organisation`)"
- :owned-object="organization"
+ :deletable-object="organization"
object-type="organization"
:object-title="organization.name"
@deleted="onOrganizationDeleted"
diff --git a/pages/admin/site/moderation.vue b/pages/admin/site/moderation.vue
index 85367b49..039779b0 100644
--- a/pages/admin/site/moderation.vue
+++ b/pages/admin/site/moderation.vue
@@ -207,7 +207,7 @@
:title="$t('Êtes-vous sûr de vouloir supprimer cet objet ?')"
:delete-url="getDeleteUrl(report.subject)!"
:delete-button-label="$t('Supprimer')"
- :owned-object="subjects[report.id]?.value"
+ :deletable-object="subjects[report.id].value!"
:object-type="getObjectType(report.subject.class)"
:object-title="getSubjectTitle(subjects[report.id]?.value)"
@deleted="() => fetchFullSubject(report, report.subject!)"
diff --git a/types/delete.ts b/types/delete.ts
index afa6274c..816ec8a9 100644
--- a/types/delete.ts
+++ b/types/delete.ts
@@ -1,2 +1,6 @@
+import type { Dataservice, DatasetV2, DatasetV2WithFullObject, Organization, Reuse, User } from '@datagouv/components-next'
+import type { Comment, Thread } from '~/types/discussions'
+
export type ObjectType = 'dataset' | 'reuse' | 'dataservice' | 'organization' | 'user' | 'discussion' | 'comment'
export type MailOption = 'auto' | 'custom'
+export type DeletableObject = DatasetV2 | DatasetV2WithFullObject | Reuse | Dataservice | Organization | User | Thread | Comment
diff --git a/utils/owner.ts b/utils/owner.ts
index 3db21d40..8d15ec53 100644
--- a/utils/owner.ts
+++ b/utils/owner.ts
@@ -1,8 +1,6 @@
-import { throwOnNever, type Dataservice, type DatasetV2, type DatasetV2WithFullObject, type Organization, type Reuse, type User } from '@datagouv/components-next'
+import { throwOnNever, type Organization, type User } from '@datagouv/components-next'
import type { $Fetch } from 'nitropack'
-import type { Comment, Thread } from '~/types/discussions'
-
-export type DeletableObject = DatasetV2 | DatasetV2WithFullObject | Reuse | Dataservice | Organization | User | Thread | Comment
+import type { DeletableObject } from '~/types/delete'
type OrganizationWithMembers = Organization & {
members: Array<{ user: User, role: string }>
From 9011fd93144d30efe483e496f7c4313799965a3b Mon Sep 17 00:00:00 2001
From: Thibaud Dauce
Date: Mon, 5 Jan 2026 15:59:31 +0100
Subject: [PATCH 10/11] Add tests on discussion creation and deletion with
normal user
---
.github/workflows/e2e.yml | 1 +
playwright.config.ts | 12 +-
tests/admin/delete-modal.normal-user.spec.ts | 43 ++++++
tests/auth-normal-user.setup.ts | 17 +++
.../discussions.normal-user.spec.ts | 134 ++++++++++++++++++
5 files changed, 206 insertions(+), 1 deletion(-)
create mode 100644 tests/admin/delete-modal.normal-user.spec.ts
create mode 100644 tests/auth-normal-user.setup.ts
create mode 100644 tests/discussions/discussions.normal-user.spec.ts
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index b532c2dd..f00ba40c 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -113,6 +113,7 @@ jobs:
uv run udata import-fixtures
uv run udata user create --first-name "Admin" --last-name "User" --email "admin@example.com" --password "@1337Password42" --admin
+ uv run udata user create --first-name "Normal" --last-name "User" --email "normal@example.com" --password "@1337Password42"
uv run inv i18nc
diff --git a/playwright.config.ts b/playwright.config.ts
index a16e5ec1..24d77c5b 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -38,16 +38,26 @@ export default defineConfig({
/* Configure projects for major browsers */
projects: [
- { name: 'setup', testMatch: /.*\.setup\.ts/ },
+ { name: 'setup', testMatch: /auth\.setup\.ts/ },
+ { name: 'setup-normal-user', testMatch: /auth-normal-user\.setup\.ts/ },
{
name: 'chromium',
+ testIgnore: /.*\.normal-user\.spec\.ts/,
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
+ {
+ name: 'chromium-normal-user',
+ testMatch: /.*\.normal-user\.spec\.ts/,
+ use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/normal-user.json' },
+ dependencies: ['setup-normal-user'],
+ },
+
{
name: 'firefox',
+ testIgnore: /.*\.normal-user\.spec\.ts/,
use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
diff --git a/tests/admin/delete-modal.normal-user.spec.ts b/tests/admin/delete-modal.normal-user.spec.ts
new file mode 100644
index 00000000..929d6d48
--- /dev/null
+++ b/tests/admin/delete-modal.normal-user.spec.ts
@@ -0,0 +1,43 @@
+import { test, expect } from '@playwright/test'
+
+const API_BASE = process.env.NUXT_PUBLIC_API_BASE || 'http://dev.local:7000'
+
+test.describe('Delete modal as normal user', () => {
+ test('does not show mail options for non-admin user', async ({ page }) => {
+ const uniqueId = Date.now()
+
+ // Create dataset via API (authenticated as normal user via setup)
+ const response = await page.request.post(`${API_BASE}/api/1/datasets/`, {
+ data: {
+ title: `Test delete normal user ${uniqueId}`,
+ description: 'Description pour test de suppression',
+ frequency: 'unknown',
+ },
+ })
+ const dataset = await response.json()
+
+ // Go to admin page for the dataset
+ await page.goto(`/admin/datasets/${dataset.id}/`)
+ await expect(page.getByRole('button', { name: 'Supprimer' })).toBeVisible()
+
+ // Open delete modal
+ await page.getByRole('button', { name: 'Supprimer' }).click()
+ await expect(page.getByRole('dialog')).toBeVisible()
+
+ const dialog = page.getByRole('dialog')
+
+ // Verify mail options are NOT shown for non-admin users
+ await expect(dialog.getByText('Notification par email')).not.toBeVisible()
+
+ // Delete button should be enabled directly (no option to select)
+ const deleteButton = dialog.getByRole('button', { name: 'Supprimer le jeu de données' })
+ await expect(deleteButton).toBeEnabled()
+
+ // Confirm deletion
+ await deleteButton.click()
+
+ // Modal should close and dataset should be marked as deleted
+ await expect(page.getByRole('dialog')).not.toBeVisible()
+ await expect(page.getByText('Restaurer ce jeu de données')).toBeVisible()
+ })
+})
diff --git a/tests/auth-normal-user.setup.ts b/tests/auth-normal-user.setup.ts
new file mode 100644
index 00000000..878f9961
--- /dev/null
+++ b/tests/auth-normal-user.setup.ts
@@ -0,0 +1,17 @@
+import { test as setup } from '@playwright/test'
+import path from 'path'
+
+const authFile = path.join(import.meta.dirname, '../playwright/.auth/normal-user.json')
+
+setup('authenticate as normal user', async ({ page, baseURL }) => {
+ const loginURL = baseURL || 'http://localhost:3000'
+
+ await page.goto(`${loginURL}/login/?next=%2F`)
+ await page.waitForLoadState('networkidle')
+ await page.getByLabel('Adresse email').fill('normal@example.com')
+ await page.getByLabel('Mot de passe').fill('@1337Password42')
+ await page.getByRole('button', { name: 'Se connecter' }).first().click()
+ await page.waitForURL(`${loginURL}/`)
+
+ await page.context().storageState({ path: authFile })
+})
diff --git a/tests/discussions/discussions.normal-user.spec.ts b/tests/discussions/discussions.normal-user.spec.ts
new file mode 100644
index 00000000..3810bbcc
--- /dev/null
+++ b/tests/discussions/discussions.normal-user.spec.ts
@@ -0,0 +1,134 @@
+import { test, expect } from '@playwright/test'
+
+const API_BASE = process.env.NUXT_PUBLIC_API_BASE || 'http://dev.local:7000'
+
+test.describe('Discussions as normal user', () => {
+ test('can create a discussion and delete it', async ({ page }) => {
+ const uniqueId = Date.now()
+
+ // Create a dataset via API
+ const response = await page.request.post(`${API_BASE}/api/1/datasets/`, {
+ data: {
+ title: `Test discussions dataset ${uniqueId}`,
+ description: 'Dataset pour tester les discussions',
+ frequency: 'unknown',
+ },
+ })
+ const dataset = await response.json()
+
+ await page.goto(`/datasets/${dataset.id}/`)
+
+ // Navigate to discussions tab
+ await page.getByRole('link', { name: 'Discussions' }).click()
+ await expect(page).toHaveURL(/\/discussions$/)
+
+ // Start a new discussion
+ await page.getByRole('button', { name: 'Démarrer une nouvelle discussion' }).click()
+
+ // Select identity (Normal User)
+ await page.getByTestId('producer-select').click()
+ await page.getByRole('option', { name: 'Normal User' }).click()
+
+ // Fill the discussion form
+ await page.getByRole('textbox', { name: /Titre/ }).click()
+ await page.getByRole('textbox', { name: /Titre/ }).fill('Ma discussion de test')
+ await page.getByRole('textbox', { name: /Votre message/ }).click()
+ await page.getByRole('textbox', { name: /Votre message/ }).fill('Ceci est le contenu de ma discussion de test.')
+
+ // Submit the discussion
+ await page.getByRole('button', { name: 'Envoyer' }).click()
+
+ // Verify discussion was created
+ await expect(page.getByText('Ma discussion de test', { exact: true })).toBeVisible()
+ await expect(page.getByText('Ceci est le contenu de ma discussion de test.')).toBeVisible()
+
+ // Delete the discussion
+ const deleteButton = page.getByRole('button', { name: 'Supprimer' }).first()
+ await deleteButton.click()
+
+ // Confirm deletion in modal
+ await expect(page.getByRole('dialog')).toBeVisible()
+ const dialog = page.getByRole('dialog')
+
+ // Verify mail options are NOT shown for non-admin users
+ await expect(dialog.getByText('Notification par email')).not.toBeVisible()
+
+ await dialog.getByRole('button', { name: 'Supprimer la discussion et les commentaires' }).click()
+
+ // Verify discussion was deleted
+ await expect(page.getByRole('dialog')).not.toBeVisible()
+ await expect(page.getByText('Ma discussion de test', { exact: true })).not.toBeVisible()
+
+ // Cleanup: delete the dataset
+ await page.request.delete(`${API_BASE}/api/1/datasets/${dataset.id}/`)
+ })
+
+ test('can add a comment to a discussion and delete only the comment', async ({ page }) => {
+ const uniqueId = Date.now()
+
+ // Create a dataset via API
+ const datasetResponse = await page.request.post(`${API_BASE}/api/1/datasets/`, {
+ data: {
+ title: `Test comments dataset ${uniqueId}`,
+ description: 'Dataset pour tester les commentaires',
+ frequency: 'unknown',
+ },
+ })
+ const dataset = await datasetResponse.json()
+
+ // Create a discussion via API
+ const discussionResponse = await page.request.post(`${API_BASE}/api/1/discussions/`, {
+ data: {
+ subject: {
+ class: 'Dataset',
+ id: dataset.id,
+ },
+ title: 'Discussion pour tester les commentaires',
+ comment: 'Premier message de la discussion.',
+ },
+ })
+ const discussion = await discussionResponse.json()
+
+ await page.goto(`/datasets/${dataset.id}/discussions`)
+
+ // Open the discussion and click reply
+ await page.getByText('Discussion pour tester les commentaires').click()
+ await page.getByRole('button', { name: 'Répondre' }).first().click()
+
+ // Select identity (Normal User)
+ await page.getByTestId('producer-select').click()
+ await page.getByRole('option', { name: 'Normal User' }).click()
+
+ // Add a comment/reply
+ await page.getByRole('textbox', { name: /Votre message/ }).click()
+ await page.getByRole('textbox', { name: /Votre message/ }).fill('Mon commentaire ajouté à la discussion.')
+ await page.getByRole('button', { name: 'Répondre', exact: true }).click()
+
+ // Verify comment was added
+ await expect(page.getByText('Mon commentaire ajouté à la discussion.')).toBeVisible()
+
+ // Find and click delete button for the comment (not the discussion)
+ // The comment delete button should be the second one (first is for the discussion)
+ const commentDeleteButton = page.getByRole('button', { name: 'Supprimer' }).nth(1)
+ await commentDeleteButton.click()
+
+ // Confirm deletion in modal
+ await expect(page.getByRole('dialog')).toBeVisible()
+ const dialog = page.getByRole('dialog')
+
+ // Verify mail options are NOT shown for non-admin users
+ await expect(dialog.getByText('Notification par email')).not.toBeVisible()
+
+ await dialog.getByRole('button', { name: 'Supprimer le commentaire' }).click()
+
+ // Verify comment was deleted but discussion still exists
+ await expect(page.getByRole('dialog')).not.toBeVisible()
+ await expect(page.getByText('Mon commentaire ajouté à la discussion.')).not.toBeVisible()
+ await expect(page.getByText('Discussion pour tester les commentaires')).toBeVisible()
+ await expect(page.getByText('Premier message de la discussion.')).toBeVisible()
+
+ // Cleanup
+ await page.request.delete(`${API_BASE}/api/1/discussions/${discussion.id}/`)
+ await page.request.delete(`${API_BASE}/api/1/datasets/${dataset.id}/`)
+ })
+})
From fc94020d7eb3aa60314ce7bd1299c642f70767ab Mon Sep 17 00:00:00 2001
From: Thibaud Dauce
Date: Mon, 5 Jan 2026 16:49:43 +0100
Subject: [PATCH 11/11] Fix missing env variable to call the API directly
---
.github/workflows/e2e.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index f00ba40c..a1668284 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -142,6 +142,7 @@ jobs:
- name: Run E2E tests
env:
BASE_URL: http://localhost:3000
+ NUXT_PUBLIC_API_BASE: http://localhost:7000
CI: true
run: pnpm run test:e2e