diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts index ed280b462f328..84379088d1a39 100644 --- a/cypress/composables/projects.ts +++ b/cypress/composables/projects.ts @@ -1,4 +1,5 @@ import { CredentialsModal, WorkflowPage } from '../pages'; +import { getVisibleSelect } from '../utils'; const workflowPage = new WorkflowPage(); const credentialsModal = new CredentialsModal(); @@ -11,18 +12,25 @@ export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a'); export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]'); export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]'); export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]'); -export const getProjectSettingsNameInput = () => cy.getByTestId('project-settings-name-input'); +export const getProjectSettingsNameInput = () => + cy.getByTestId('project-settings-name-input').find('input'); export const getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button'); export const getProjectSettingsCancelButton = () => cy.getByTestId('project-settings-cancel-button'); export const getProjectSettingsDeleteButton = () => cy.getByTestId('project-settings-delete-button'); export const getProjectMembersSelect = () => cy.getByTestId('project-members-select'); -export const addProjectMember = (email: string) => { +export const addProjectMember = (email: string, role?: string) => { getProjectMembersSelect().click(); getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click(); + + if (role) { + cy.getByTestId(`user-list-item-${email}`) + .find('[data-test-id="projects-settings-user-role-select"]') + .click(); + getVisibleSelect().find('li').contains(role).click(); + } }; -export const getProjectNameInput = () => cy.get('#projectName').find('input'); export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal'); export const getResourceMoveConfirmModal = () => cy.getByTestId('project-move-resource-confirm-modal'); @@ -31,12 +39,7 @@ export const getProjectMoveSelect = () => cy.getByTestId('project-move-resource- export function createProject(name: string) { getAddProjectButton().click(); - getProjectNameInput() - .should('be.visible') - .should('be.focused') - .should('have.value', 'My project') - .clear() - .type(name); + getProjectSettingsNameInput().should('be.visible').clear().type(name); getProjectSettingsSaveButton().click(); } diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts index 9b05cb84d4130..53dad1cc89669 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -145,7 +145,16 @@ describe('Canvas Actions', () => { }); }); - it('should delete connections by pressing the delete button', () => { + it('should delete node by pressing keyboard backspace', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click(); + cy.get('body').type('{backspace}'); + WorkflowPage.getters.nodeConnections().should('have.length', 0); + }); + + it('should delete connections by clicking on the delete button', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts index ab1ee3955b1ac..64769ae1935b8 100644 --- a/cypress/e2e/17-sharing.cy.ts +++ b/cypress/e2e/17-sharing.cy.ts @@ -264,6 +264,7 @@ describe('Sharing', { disableAutoLogin: true }, () => { describe('Credential Usage in Cross Shared Workflows', () => { beforeEach(() => { cy.resetDatabase(); + cy.enableFeature('sharing'); cy.enableFeature('advancedPermissions'); cy.enableFeature('projectRole:admin'); cy.enableFeature('projectRole:editor'); @@ -274,11 +275,6 @@ describe('Credential Usage in Cross Shared Workflows', () => { }); it('should only show credentials from the same team project', () => { - cy.enableFeature('advancedPermissions'); - cy.enableFeature('projectRole:admin'); - cy.enableFeature('projectRole:editor'); - cy.changeQuota('maxTeamProjects', -1); - // Create a notion credential in the home project credentialsPage.getters.emptyListCreateCredentialButton().click(); credentialsModal.actions.createNewCredential('Notion API'); @@ -305,10 +301,36 @@ describe('Credential Usage in Cross Shared Workflows', () => { getVisibleSelect().find('li').should('have.length', 2); }); + it('should only show credentials in their personal project for members', () => { + // Create a notion credential as the owner + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + + // Create another notion credential as the owner, but share it with member + // 0 + credentialsPage.getters.createCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API', false); + credentialsModal.actions.changeTab('Sharing'); + credentialsModal.actions.addUser(INSTANCE_MEMBERS[0].email); + credentialsModal.actions.saveSharing(); + + // As the member, create a new notion credential and a workflow + cy.signinAsMember(); + cy.visit(credentialsPage.url); + credentialsPage.getters.createCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + cy.visit(workflowsPage.url); + workflowsPage.actions.createWorkflowFromCard(); + workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); + + // Only the own credential the shared one (+ the 'Create new' option) + // should be in the dropdown + workflowPage.getters.nodeCredentialsSelect().click(); + getVisibleSelect().find('li').should('have.length', 3); + }); + it('should only show credentials in their personal project for members if the workflow was shared with them', () => { const workflowName = 'Test workflow'; - cy.enableFeature('sharing'); - cy.reload(); // Create a notion credential as the owner and a workflow that is shared // with member 0 @@ -339,7 +361,6 @@ describe('Credential Usage in Cross Shared Workflows', () => { it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => { const workflowName = 'Test workflow'; - cy.enableFeature('sharing'); // As member 1, create a new notion credential. This should not show up. cy.signinAsMember(1); @@ -384,8 +405,6 @@ describe('Credential Usage in Cross Shared Workflows', () => { }); it('should show all personal credentials if the global owner owns the workflow', () => { - cy.enableFeature('sharing'); - // As member 0, create a new notion credential. cy.signinAsMember(); cy.visit(credentialsPage.url); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index cbf7324e4b6b3..e2bf63df7dc46 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -1,9 +1,4 @@ -import { - INSTANCE_MEMBERS, - INSTANCE_OWNER, - MANUAL_TRIGGER_NODE_NAME, - NOTION_NODE_NAME, -} from '../constants'; +import { INSTANCE_MEMBERS, MANUAL_TRIGGER_NODE_NAME, NOTION_NODE_NAME } from '../constants'; import { WorkflowsPage, WorkflowPage, @@ -11,9 +6,10 @@ import { CredentialsPage, WorkflowExecutionsTab, NDV, + MainSidebar, } from '../pages'; import * as projects from '../composables/projects'; -import { getVisibleSelect } from '../utils'; +import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils'; const workflowsPage = new WorkflowsPage(); const workflowPage = new WorkflowPage(); @@ -21,6 +17,7 @@ const credentialsPage = new CredentialsPage(); const credentialsModal = new CredentialsModal(); const executionsTab = new WorkflowExecutionsTab(); const ndv = new NDV(); +const mainSidebar = new MainSidebar(); describe('Projects', { disableAutoLogin: true }, () => { before(() => { @@ -241,6 +238,26 @@ describe('Projects', { disableAutoLogin: true }, () => { projects.getMenuItems().should('not.exist'); }); + it('should not show viewer role if not licensed', () => { + cy.signinAsOwner(); + cy.visit(workflowsPage.url); + + projects.getMenuItems().first().click(); + projects.getProjectTabSettings().click(); + + cy.get( + `[data-test-id="user-list-item-${INSTANCE_MEMBERS[0].email}"] [data-test-id="projects-settings-user-role-select"]`, + ).click(); + + cy.get('.el-select-dropdown__item.is-disabled') + .should('contain.text', 'Viewer') + .get('span:contains("Upgrade")') + .filter(':visible') + .click(); + + getVisibleModalOverlay().should('contain.text', 'Upgrade to unlock additional roles'); + }); + describe('when starting from scratch', () => { beforeEach(() => { cy.resetDatabase(); @@ -257,7 +274,7 @@ describe('Projects', { disableAutoLogin: true }, () => { // Create a project and add a credential to it cy.intercept('POST', '/rest/projects').as('projectCreate'); - projects.getAddProjectButton().should('contain', 'Add project').should('be.visible').click(); + projects.getAddProjectButton().click(); cy.wait('@projectCreate'); projects.getMenuItems().should('have.length', 1); projects.getMenuItems().first().click(); @@ -418,7 +435,7 @@ describe('Projects', { disableAutoLogin: true }, () => { }); it('should move resources between projects', () => { - cy.signin(INSTANCE_OWNER); + cy.signinAsOwner(); cy.visit(workflowsPage.url); // Create a workflow and a credential in the Home project @@ -563,5 +580,80 @@ describe('Projects', { disableAutoLogin: true }, () => { projects.getProjectTabCredentials().click(); credentialsPage.getters.credentialCards().should('have.length', 2); }); + + it('should handle viewer role', () => { + cy.enableFeature('projectRole:viewer'); + cy.signinAsOwner(); + cy.visit(workflowsPage.url); + + projects.createProject('Development'); + projects.addProjectMember(INSTANCE_MEMBERS[0].email, 'Viewer'); + projects.getProjectSettingsSaveButton().click(); + + projects.getProjectTabWorkflows().click(); + workflowsPage.getters.newWorkflowButtonCard().click(); + projects.createWorkflow('Test_workflow_4_executions_view.json', 'WF with random error'); + executionsTab.actions.createManualExecutions(2); + executionsTab.actions.toggleNodeEnabled('Error'); + executionsTab.actions.createManualExecutions(2); + workflowPage.actions.saveWorkflowUsingKeyboardShortcut(); + + projects.getMenuItems().first().click(); + projects.getProjectTabCredentials().click(); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + projects.createCredential('Notion API'); + + mainSidebar.actions.openUserMenu(); + cy.getByTestId('user-menu-item-logout').click(); + + cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email); + cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password); + cy.getByTestId('form-submit-button').click(); + + mainSidebar.getters.executions().click(); + cy.getByTestId('global-execution-list-item').first().find('td:last button').click(); + getVisibleDropdown() + .find('li') + .filter(':contains("Retry")') + .should('have.class', 'is-disabled'); + getVisibleDropdown() + .find('li') + .filter(':contains("Delete")') + .should('have.class', 'is-disabled'); + + projects.getMenuItems().first().click(); + cy.getByTestId('workflow-card-name').should('be.visible').first().click(); + workflowPage.getters.nodeViewRoot().should('be.visible'); + workflowPage.getters.executeWorkflowButton().should('not.exist'); + workflowPage.getters.nodeCreatorPlusButton().should('not.exist'); + workflowPage.getters.canvasNodes().should('have.length', 3).last().click(); + cy.get('body').type('{backspace}'); + workflowPage.getters.canvasNodes().should('have.length', 3).last().rightclick(); + getVisibleDropdown() + .find('li') + .should('be.visible') + .filter( + ':contains("Open"), :contains("Copy"), :contains("Select all"), :contains("Clear selection")', + ) + .should('not.have.class', 'is-disabled'); + cy.get('body').type('{esc}'); + + executionsTab.actions.switchToExecutionsTab(); + cy.getByTestId('retry-execution-button') + .should('be.visible') + .find('.is-disabled') + .should('exist'); + cy.get('button:contains("Debug")').should('be.disabled'); + cy.get('button[title="Retry execution"]').should('be.disabled'); + cy.get('button[title="Delete this execution"]').should('be.disabled'); + + projects.getMenuItems().first().click(); + projects.getProjectTabCredentials().click(); + credentialsPage.getters.credentialCards().filter(':contains("Notion")').click(); + cy.getByTestId('node-credentials-config-container') + .should('be.visible') + .find('input') + .should('not.have.length'); + }); }); }); diff --git a/packages/@n8n/permissions/src/constants.ts b/packages/@n8n/permissions/src/constants.ts new file mode 100644 index 0000000000000..de027891c59e9 --- /dev/null +++ b/packages/@n8n/permissions/src/constants.ts @@ -0,0 +1,23 @@ +export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const; +export const RESOURCES = { + auditLogs: ['manage'] as const, + banner: ['dismiss'] as const, + communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const, + credential: ['share', 'move', ...DEFAULT_OPERATIONS] as const, + externalSecretsProvider: ['sync', ...DEFAULT_OPERATIONS] as const, + externalSecret: ['list', 'use'] as const, + eventBusDestination: ['test', ...DEFAULT_OPERATIONS] as const, + ldap: ['sync', 'manage'] as const, + license: ['manage'] as const, + logStreaming: ['manage'] as const, + orchestration: ['read', 'list'] as const, + project: [...DEFAULT_OPERATIONS] as const, + saml: ['manage'] as const, + securityAudit: ['generate'] as const, + sourceControl: ['pull', 'push', 'manage'] as const, + tag: [...DEFAULT_OPERATIONS] as const, + user: ['resetPassword', 'changeRole', ...DEFAULT_OPERATIONS] as const, + variable: [...DEFAULT_OPERATIONS] as const, + workersView: ['manage'] as const, + workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const, +} as const; diff --git a/packages/@n8n/permissions/src/index.ts b/packages/@n8n/permissions/src/index.ts index 0d3e510abe67a..f04f2e4ef68ac 100644 --- a/packages/@n8n/permissions/src/index.ts +++ b/packages/@n8n/permissions/src/index.ts @@ -1,3 +1,4 @@ export type * from './types'; +export * from './constants'; export * from './hasScope'; export * from './combineScopes'; diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 2720272e6fd75..bc714734d35aa 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -1,25 +1,7 @@ -export type DefaultOperations = 'create' | 'read' | 'update' | 'delete' | 'list'; -export type Resource = - | 'auditLogs' - | 'banner' - | 'communityPackage' - | 'credential' - | 'externalSecretsProvider' - | 'externalSecret' - | 'eventBusDestination' - | 'ldap' - | 'license' - | 'logStreaming' - | 'orchestration' - | 'project' - | 'saml' - | 'securityAudit' - | 'sourceControl' - | 'tag' - | 'user' - | 'variable' - | 'workersView' - | 'workflow'; +import type { DEFAULT_OPERATIONS, RESOURCES } from './constants'; + +export type DefaultOperations = (typeof DEFAULT_OPERATIONS)[number]; +export type Resource = keyof typeof RESOURCES; export type ResourceScope< R extends Resource, diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue index 94f2e1376e702..575895ab1585b 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -15,13 +15,19 @@ - + + + + should render correctly 1`] = `
- + + + " `; diff --git a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue index 79a8e883f9fb3..649d4bc570491 100644 --- a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue +++ b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue @@ -6,6 +6,7 @@ :trigger="trigger" :popper-class="popperClass" :teleported="teleported" + :disabled="disabled" @command="onSelect" @visible-change="onVisibleChange" > @@ -76,6 +77,7 @@ interface ActionDropdownProps { trigger?: (typeof TRIGGER)[number]; hideArrow?: boolean; teleported?: boolean; + disabled?: boolean; } const props = withDefaults(defineProps(), { @@ -86,6 +88,7 @@ const props = withDefaults(defineProps(), { trigger: 'click', hideArrow: false, teleported: true, + disabled: false, }); const attrs = useAttrs(); diff --git a/packages/design-system/src/css/dropdown.scss b/packages/design-system/src/css/dropdown.scss index a15d17f0268a4..e3ff966a4fcdf 100644 --- a/packages/design-system/src/css/dropdown.scss +++ b/packages/design-system/src/css/dropdown.scss @@ -19,6 +19,14 @@ } } + @include mixins.when(disabled) { + .el-tooltip__trigger { + opacity: 0.25; + cursor: not-allowed; + background: unset; + } + } + & .el-dropdown__caret-button { padding-left: 5px; padding-right: 5px; diff --git a/packages/editor-ui/src/components/CredentialCard.vue b/packages/editor-ui/src/components/CredentialCard.vue index d9ac12ffd9a54..33ffefa37811f 100644 --- a/packages/editor-ui/src/components/CredentialCard.vue +++ b/packages/editor-ui/src/components/CredentialCard.vue @@ -5,7 +5,7 @@ import type { ICredentialsResponse } from '@/Interface'; import { MODAL_CONFIRM, PROJECT_MOVE_RESOURCE_MODAL } from '@/constants'; import { useMessage } from '@/composables/useMessage'; import CredentialIcon from '@/components/CredentialIcon.vue'; -import { getCredentialPermissions } from '@/permissions'; +import { getResourcePermissions } from '@/permissions'; import { useUIStore } from '@/stores/ui.store'; import { useCredentialsStore } from '@/stores/credentials.store'; import TimeAgo from '@/components/TimeAgo.vue'; @@ -48,7 +48,7 @@ const projectsStore = useProjectsStore(); const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase()); const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type)); -const credentialPermissions = computed(() => getCredentialPermissions(props.data)); +const credentialPermissions = computed(() => getResourcePermissions(props.data.scopes).credential); const actions = computed(() => { const items = [ { diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue index 1bfda209de447..ca79b07f836dd 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue @@ -172,14 +172,13 @@ import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue'; import { useI18n } from '@/composables/useI18n'; import { useTelemetry } from '@/composables/useTelemetry'; import { BUILTIN_CREDENTIALS_DOCS_URL, DOCS_DOMAIN, EnterpriseEditionFeature } from '@/constants'; -import type { PermissionsMap } from '@/permissions'; +import type { PermissionsRecord } from '@/permissions'; import { addCredentialTranslation } from '@/plugins/i18n'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useNDVStore } from '@/stores/ndv.store'; import { useRootStore } from '@/stores/root.store'; import { useUIStore } from '@/stores/ui.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import type { CredentialScope } from '@n8n/permissions'; import Banner from '../Banner.vue'; import CopyInput from '../CopyInput.vue'; import CredentialInputs from './CredentialInputs.vue'; @@ -194,7 +193,7 @@ type Props = { credentialProperties: INodeProperties[]; credentialData: ICredentialDataDecryptedObject; credentialId?: string; - credentialPermissions?: PermissionsMap; + credentialPermissions: PermissionsRecord['credential']; parentTypes?: string[]; showValidationWarning?: boolean; authError?: string; @@ -212,7 +211,7 @@ const props = withDefaults(defineProps(), { credentialId: '', authError: '', showValidationWarning: false, - credentialPermissions: () => ({}) as PermissionsMap, + credentialPermissions: () => ({}) as PermissionsRecord['credential'], }); const emit = defineEmits<{ update: [value: IUpdateInformation]; diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index bfeab0737ceb5..e2c7c428e5a36 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -145,8 +145,7 @@ import { useMessage } from '@/composables/useMessage'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useToast } from '@/composables/useToast'; import { CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants'; -import type { PermissionsMap } from '@/permissions'; -import { getCredentialPermissions } from '@/permissions'; +import { getResourcePermissions } from '@/permissions'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useNDVStore } from '@/stores/ndv.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; @@ -169,7 +168,6 @@ import { updateNodeAuthType, } from '@/utils/nodeTypesUtils'; import { isCredentialModalState, isValidCredentialResponse } from '@/utils/typeGuards'; -import type { CredentialScope } from '@n8n/permissions'; type Props = { modalName: string; @@ -395,14 +393,11 @@ const requiredPropertiesFilled = computed(() => { return true; }); -const credentialPermissions = computed>(() => { - if (loading.value) { - return {} as PermissionsMap; - } - - return getCredentialPermissions( - (credentialId.value ? currentCredential.value : credentialData.value) as ICredentialsResponse, - ); +const credentialPermissions = computed(() => { + return getResourcePermissions( + ((credentialId.value ? currentCredential.value : credentialData.value) as ICredentialsResponse) + ?.scopes, + ).credential; }); const sidebarItems = computed(() => { diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue index 0d19cc3b4b191..f86e48a0c5a2c 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue @@ -68,8 +68,7 @@ import { useProjectsStore } from '@/stores/projects.store'; import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; -import type { PermissionsMap } from '@/permissions'; -import type { CredentialScope } from '@n8n/permissions'; +import type { PermissionsRecord } from '@/permissions'; import type { EventBus } from 'n8n-design-system/utils'; import { useRolesStore } from '@/stores/roles.store'; import type { RoleMap } from '@/types/roles.types'; @@ -94,7 +93,7 @@ export default defineComponent({ required: true, }, credentialPermissions: { - type: Object as PropType>, + type: Object as PropType, required: true, }, modalBus: { diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index 9a5eb14dfbb5c..f9c84156a6d56 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -13,9 +13,6 @@ import { WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY, } from '@/constants'; -import type { PermissionsMap } from '@/permissions'; -import type { WorkflowScope } from '@n8n/permissions'; - import ShortenName from '@/components/ShortenName.vue'; import TagsContainer from '@/components/TagsContainer.vue'; import PushConnectionTracker from '@/components/PushConnectionTracker.vue'; @@ -38,8 +35,7 @@ import { saveAs } from 'file-saver'; import { useTitleChange } from '@/composables/useTitleChange'; import { useMessage } from '@/composables/useMessage'; import { useToast } from '@/composables/useToast'; - -import { getWorkflowPermissions } from '@/permissions'; +import { getResourcePermissions } from '@/permissions'; import { createEventBus } from 'n8n-design-system/utils'; import { nodeViewEventBus } from '@/event-bus'; import { hasPermission } from '@/utils/rbac/permissions'; @@ -55,7 +51,7 @@ import type { } from '@/Interface'; import { useI18n } from '@/composables/useI18n'; import { useTelemetry } from '@/composables/useTelemetry'; -import type { BaseTextKey } from '../../plugins/i18n'; +import type { BaseTextKey } from '@/plugins/i18n'; import { useNpsSurveyStore } from '@/stores/npsSurvey.store'; import { useLocalStorage } from '@vueuse/core'; @@ -132,9 +128,9 @@ const onExecutionsTab = computed(() => { ].includes((route.name as string) || ''); }); -const workflowPermissions = computed>(() => { - return getWorkflowPermissions(workflowsStore.getWorkflowById(props.workflow.id)); -}); +const workflowPermissions = computed( + () => getResourcePermissions(workflowsStore.getWorkflowById(props.workflow.id)?.scopes).workflow, +); const workflowMenuItems = computed(() => { const actions: ActionDropdownItem[] = [ @@ -145,7 +141,7 @@ const workflowMenuItems = computed(() => { }, ]; - if (!props.readOnly) { + if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) { actions.unshift({ id: WORKFLOW_MENU_ACTIONS.DUPLICATE, label: locale.baseText('menuActions.duplicate'), @@ -623,7 +619,7 @@ function showCreateWorkflowSuccessToast(id?: string) { :preview-value="shortenedName" :is-edit-enabled="isNameEditEnabled" :max-length="MAX_WORKFLOW_NAME_LENGTH" - :disabled="readOnly" + :disabled="readOnly || (!isNewWorkflow && !workflowPermissions.update)" placeholder="Enter workflow name" class="name" @toggle="onNameToggle" @@ -636,7 +632,7 @@ function showCreateWorkflowSuccessToast(id?: string) { -
+
+ {{ $locale.baseText('workflowDetails.addTag') }} @@ -665,7 +667,11 @@ function showCreateWorkflowSuccessToast(id?: string) { - +
@@ -709,9 +715,11 @@ function showCreateWorkflowSuccessToast(id?: string) {
- +
-