From 000cccb62700144fd41ed70e2177de1c3cb32c31 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Mon, 24 Nov 2025 21:44:27 +0100 Subject: [PATCH] fix(editor): Missing duplicate workflow action on workflow list (#22230) --- .../src/app/components/WorkflowCard.test.ts | 144 ++++++++++++++++++ .../src/app/components/WorkflowCard.vue | 21 ++- 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts index 9db00936c3e..7c4ae220c5e 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts @@ -6,13 +6,16 @@ import { type MockedStore, mockedStore } from '@/__tests__/utils'; import { MODAL_CONFIRM, VIEWS } from '@/app/constants'; import WorkflowCard from '@/app/components/WorkflowCard.vue'; import type { WorkflowResource } from '@/Interface'; +import type { IUser } from '@n8n/rest-api-client/api/users'; import * as vueRouter from 'vue-router'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; +import type { ProjectListItem } from '@/features/collaboration/projects/projects.types'; import { useMessage } from '@/app/composables/useMessage'; import { useToast } from '@/app/composables/useToast'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { createTestingPinia } from '@pinia/testing'; import { useSettingsStore } from '@/app/stores/settings.store'; +import { useUsersStore } from '@/features/settings/users/users.store'; vi.mock('vue-router', () => { const push = vi.fn(); @@ -73,6 +76,7 @@ describe('WorkflowCard', () => { let projectsStore: MockedStore; let settingsStore: MockedStore; let workflowsStore: MockedStore; + let usersStore: MockedStore; let message: ReturnType; let toast: ReturnType; @@ -81,6 +85,7 @@ describe('WorkflowCard', () => { projectsStore = mockedStore(useProjectsStore); settingsStore = mockedStore(useSettingsStore); workflowsStore = mockedStore(useWorkflowsStore); + usersStore = mockedStore(useUsersStore); message = useMessage(); toast = useToast(); @@ -631,4 +636,143 @@ describe('WorkflowCard', () => { expect(queryByTestId('workflow-card-archived')).not.toBeInTheDocument(); expect(queryByTestId('workflow-card-activator')).toBeInTheDocument(); }); + + it("should show 'Duplicate' action when user has read permission and can create workflows", async () => { + const data = createWorkflow({ + scopes: ['workflow:read'], + isArchived: false, + }); + + vi.spyOn(vueRouter, 'useRoute').mockReturnValueOnce({ + name: VIEWS.PROJECTS, + } as vueRouter.RouteLocationNormalizedLoadedGeneric); + + // Mock user with global workflow create permission + usersStore.currentUser = { + id: '1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + isDefaultUser: false, + isPendingUser: false, + mfaEnabled: false, + globalScopes: ['workflow:create'], + } as IUser; + + const { getByTestId } = renderComponent({ props: { data } }); + const cardActions = getByTestId('workflow-card-actions'); + + expect(cardActions).toBeInTheDocument(); + + const cardActionsOpener = within(cardActions).getByRole('button'); + expect(cardActionsOpener).toBeInTheDocument(); + + const controllingId = cardActionsOpener.getAttribute('aria-controls'); + + await userEvent.click(cardActions); + const actions = document.querySelector(`#${controllingId}`); + if (!actions) { + throw new Error('Actions menu not found'); + } + expect(actions).toHaveTextContent('Duplicate'); + }); + + it("should show 'Duplicate' action when user has project-level create workflow permission", async () => { + const projectId = 'project-123'; + const data = createWorkflow({ + scopes: ['workflow:read'], + isArchived: false, + homeProject: { + id: projectId, + name: 'Test Project', + }, + }); + + vi.spyOn(vueRouter, 'useRoute').mockReturnValueOnce({ + name: VIEWS.PROJECTS, + } as vueRouter.RouteLocationNormalizedLoadedGeneric); + + // Mock user without global workflow create permission + usersStore.currentUser = { + id: '1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + isDefaultUser: false, + isPendingUser: false, + mfaEnabled: false, + globalScopes: [], + } as IUser; + + // Mock project with workflow create permission + projectsStore.myProjects = [ + { + id: projectId, + name: 'Test Project', + icon: null, + type: 'team', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + role: 'project:editor', + scopes: ['workflow:create'], + } as ProjectListItem, + ]; + + const { getByTestId } = renderComponent({ props: { data } }); + const cardActions = getByTestId('workflow-card-actions'); + + expect(cardActions).toBeInTheDocument(); + + const cardActionsOpener = within(cardActions).getByRole('button'); + expect(cardActionsOpener).toBeInTheDocument(); + + const controllingId = cardActionsOpener.getAttribute('aria-controls'); + + await userEvent.click(cardActions); + const actions = document.querySelector(`#${controllingId}`); + if (!actions) { + throw new Error('Actions menu not found'); + } + expect(actions).toHaveTextContent('Duplicate'); + }); + + it("should not show 'Duplicate' action when user does not have create workflow permission", async () => { + const data = createWorkflow({ + scopes: ['workflow:read'], + isArchived: false, + }); + + vi.spyOn(vueRouter, 'useRoute').mockReturnValueOnce({ + name: VIEWS.PROJECTS, + } as vueRouter.RouteLocationNormalizedLoadedGeneric); + + // Mock user without workflow create permission + usersStore.currentUser = { + id: '1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + isDefaultUser: false, + isPendingUser: false, + mfaEnabled: false, + globalScopes: [], + } as IUser; + + const { getByTestId } = renderComponent({ props: { data } }); + const cardActions = getByTestId('workflow-card-actions'); + + expect(cardActions).toBeInTheDocument(); + + const cardActionsOpener = within(cardActions).getByRole('button'); + expect(cardActionsOpener).toBeInTheDocument(); + + const controllingId = cardActionsOpener.getAttribute('aria-controls'); + + await userEvent.click(cardActions); + const actions = document.querySelector(`#${controllingId}`); + if (!actions) { + throw new Error('Actions menu not found'); + } + expect(actions).not.toHaveTextContent('Duplicate'); + }); }); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue index 85b2a29b037..177c440e34d 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue @@ -121,6 +121,20 @@ const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toL const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser)); const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow); +const globalPermissions = computed( + () => getResourcePermissions(usersStore.currentUser?.globalScopes).workflow, +); +const projectPermissions = computed( + () => + getResourcePermissions( + projectsStore.myProjects?.find((p) => props.data.homeProject?.id === p.id)?.scopes, + ).workflow, +); + +const canCreateWorkflow = computed( + () => globalPermissions.value.create ?? projectPermissions.value.create, +); + const showFolders = computed(() => { return props.areFoldersEnabled && route.name !== VIEWS.WORKFLOWS; }); @@ -168,7 +182,12 @@ const actions = computed(() => { }, ]; - if (workflowPermissions.value.create && !props.readOnly && !props.data.isArchived) { + if ( + workflowPermissions.value.read && + canCreateWorkflow.value && + !props.readOnly && + !props.data.isArchived + ) { items.push({ label: locale.baseText('workflows.item.duplicate'), value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,