fix(editor): Connect up new project viewer role to the FE (#9913)

This commit is contained in:
Csaba Tuncsik 2024-08-13 15:45:28 +02:00 committed by GitHub
parent 56c4692c94
commit 117e2d968f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1482 additions and 1155 deletions

View file

@ -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();
}

View file

@ -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);

View file

@ -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);

View file

@ -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');
});
});
});

View file

@ -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;

View file

@ -1,3 +1,4 @@
export type * from './types';
export * from './constants';
export * from './hasScope';
export * from './combineScopes';

View file

@ -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,

View file

@ -15,13 +15,19 @@
</slot>
</N8nText>
</div>
<N8nButton
v-if="buttonText"
:label="buttonText"
:type="buttonType"
size="large"
@click="$emit('click:button', $event)"
/>
<N8nTooltip :disabled="!buttonDisabled">
<template #content>
<slot name="disabledButtonTooltip"></slot>
</template>
<N8nButton
v-if="buttonText"
:label="buttonText"
:type="buttonType"
:disabled="buttonDisabled"
size="large"
@click="$emit('click:button', $event)"
/>
</N8nTooltip>
<N8nCallout
v-if="calloutText"
:theme="calloutTheme"
@ -41,12 +47,14 @@ import N8nHeading from '../N8nHeading';
import N8nText from '../N8nText';
import N8nCallout, { type CalloutTheme } from '../N8nCallout';
import type { ButtonType } from 'n8n-design-system/types/button';
import N8nTooltip from 'n8n-design-system/components/N8nTooltip/Tooltip.vue';
interface ActionBoxProps {
emoji: string;
heading: string;
buttonText: string;
buttonType: ButtonType;
buttonDisabled?: boolean;
description: string;
calloutText?: string;
calloutTheme?: CalloutTheme;

View file

@ -9,7 +9,9 @@ exports[`N8NActionBox > should render correctly 1`] = `
<div class="description">
<n8n-text-stub color="text-base" bold="false" size="medium" compact="false" tag="span"></n8n-text-stub>
</div>
<n8n-button-stub block="false" element="button" label="Do something" square="false" active="false" disabled="false" loading="false" outline="false" size="large" text="false" type="primary"></n8n-button-stub>
<n8n-button-stub block="false" element="button" label="Do something" square="false" active="false" disabled="false" loading="false" outline="false" size="large" text="false" type="primary" class="el-tooltip__trigger"></n8n-button-stub>
<!--teleport start-->
<!--teleport end-->
<!--v-if-->
</div>"
`;

View file

@ -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<ActionDropdownProps>(), {
@ -86,6 +88,7 @@ const props = withDefaults(defineProps<ActionDropdownProps>(), {
trigger: 'click',
hideArrow: false,
teleported: true,
disabled: false,
});
const attrs = useAttrs();

View file

@ -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;

View file

@ -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 = [
{

View file

@ -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<CredentialScope>;
credentialPermissions: PermissionsRecord['credential'];
parentTypes?: string[];
showValidationWarning?: boolean;
authError?: string;
@ -212,7 +211,7 @@ const props = withDefaults(defineProps<Props>(), {
credentialId: '',
authError: '',
showValidationWarning: false,
credentialPermissions: () => ({}) as PermissionsMap<CredentialScope>,
credentialPermissions: () => ({}) as PermissionsRecord['credential'],
});
const emit = defineEmits<{
update: [value: IUpdateInformation];

View file

@ -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<PermissionsMap<CredentialScope>>(() => {
if (loading.value) {
return {} as PermissionsMap<CredentialScope>;
}
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(() => {

View file

@ -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<PermissionsMap<CredentialScope>>,
type: Object as PropType<PermissionsRecord['credential']>,
required: true,
},
modalBus: {

View file

@ -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';
@ -140,9 +136,9 @@ const onExecutionsTab = computed(() => {
].includes((route.name as string) || '');
});
const workflowPermissions = computed<PermissionsMap<WorkflowScope>>(() => {
return getWorkflowPermissions(workflowsStore.getWorkflowById(props.workflow.id));
});
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.getWorkflowById(props.workflow.id)?.scopes).workflow,
);
const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
const actions: ActionDropdownItem[] = [
@ -153,7 +149,7 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
},
];
if (!props.readOnly) {
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
actions.unshift({
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
label: locale.baseText('menuActions.duplicate'),
@ -631,7 +627,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"
@ -644,7 +640,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
<TagsDropdown
v-if="isTagsEditEnabled && !readOnly"
v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)"
ref="dropdown"
v-model="appliedTagIds"
:event-bus="tagsEventBus"
@ -654,7 +650,13 @@ function showCreateWorkflowSuccessToast(id?: string) {
@blur="onTagsBlur"
@esc="onTagsEditEsc"
/>
<div v-else-if="(workflow.tags ?? []).length === 0 && !readOnly">
<div
v-else-if="
(workflow.tags ?? []).length === 0 &&
!readOnly &&
(isNewWorkflow || workflowPermissions.update)
"
>
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
+ {{ $locale.baseText('workflowDetails.addTag') }}
</span>
@ -673,7 +675,11 @@ function showCreateWorkflowSuccessToast(id?: string) {
<PushConnectionTracker class="actions">
<span :class="`activator ${$style.group}`">
<WorkflowActivator :workflow-active="workflow.active" :workflow-id="workflow.id" />
<WorkflowActivator
:workflow-active="workflow.active"
:workflow-id="workflow.id"
:workflow-permissions="workflowPermissions"
/>
</span>
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
<div :class="$style.group">
@ -717,9 +723,11 @@ function showCreateWorkflowSuccessToast(id?: string) {
<SaveButton
type="primary"
:saved="!uiStore.stateIsDirty && !isNewWorkflow"
:disabled="isWorkflowSaving || readOnly"
:disabled="
isWorkflowSaving || readOnly || (!isNewWorkflow && !workflowPermissions.update)
"
:is-saving="isWorkflowSaving"
with-shortcut
:with-shortcut="!readOnly && workflowPermissions.update"
:shortcut-tooltip="$locale.baseText('saveWorkflowButton.hint')"
data-test-id="workflow-save-button"
@click="onSaveButtonClick"

View file

@ -56,12 +56,7 @@
<div :class="$style.userArea">
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
<!-- This dropdown is only enabled when sidebar is collapsed -->
<el-dropdown
:disabled="!isCollapsed"
placement="right-end"
trigger="click"
@command="onUserActionToggle"
>
<el-dropdown placement="right-end" trigger="click" @command="onUserActionToggle">
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
<n8n-avatar
:first-name="usersStore.currentUser?.firstName"
@ -69,7 +64,7 @@
size="small"
/>
</div>
<template #dropdown>
<template v-if="isCollapsed" #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="settings">
{{ $locale.baseText('settings') }}

View file

@ -5,7 +5,7 @@ import { useRoute } from 'vue-router';
import { VIEWS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useProjectsStore } from '@/stores/projects.store';
import { getProjectPermissions } from '@/permissions';
import { getResourcePermissions } from '@/permissions';
const locale = useI18n();
const route = useRoute();
@ -13,6 +13,9 @@ const route = useRoute();
const projectsStore = useProjectsStore();
const selectedTab = ref<RouteRecordName | null | undefined>('');
const projectPermissions = computed(
() => getResourcePermissions(projectsStore.currentProject?.scopes).project,
);
const options = computed(() => {
const projectId = route?.params?.projectId;
const to = projectId
@ -47,7 +50,7 @@ const options = computed(() => {
},
];
if (projectId && getProjectPermissions(projectsStore.currentProject).update) {
if (projectId && projectPermissions.value.update) {
tabs.push({
label: locale.baseText('projects.settings'),
value: VIEWS.PROJECT_SETTINGS,

View file

@ -8,7 +8,7 @@ import { useClipboard } from '@/composables/useClipboard';
import { EnterpriseEditionFeature } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { getVariablesPermissions } from '@/permissions';
import { getResourcePermissions } from '@/permissions';
import type { IResource } from './layouts/ResourcesListLayout.vue';
const i18n = useI18n();
@ -34,7 +34,9 @@ const props = withDefaults(
},
);
const permissions = computed(() => getVariablesPermissions(usersStore.currentUser));
const permissions = computed(
() => getResourcePermissions(usersStore.currentUser?.globalScopes).variable,
);
const modelValue = ref<IResource>({ ...props.data });
const formValidationStatus = ref<Record<string, boolean>>({

View file

@ -5,8 +5,14 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { getActivatableTriggerNodes } from '@/utils/nodeTypesUtils';
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { PermissionsRecord } from '@/permissions';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
const props = defineProps<{ workflowActive: boolean; workflowId: string }>();
const props = defineProps<{
workflowActive: boolean;
workflowId: string;
workflowPermissions: PermissionsRecord['workflow'];
}>();
const { showMessage } = useToast();
const workflowActivate = useWorkflowActivate();
@ -35,9 +41,15 @@ const containsTrigger = computed((): boolean => {
return foundTriggers.length > 0;
});
const isNewWorkflow = computed(
() =>
!props.workflowId ||
props.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID ||
props.workflowId === 'new',
);
const disabled = computed((): boolean => {
const isNewWorkflow = !props.workflowId;
if (isNewWorkflow || isCurrentWorkflow.value) {
if (isNewWorkflow.value || isCurrentWorkflow.value) {
return !props.workflowActive && !containsTrigger.value;
}
@ -108,7 +120,11 @@ async function displayActivationError() {
? i18n.baseText('workflowActivator.deactivateWorkflow')
: i18n.baseText('workflowActivator.activateWorkflow')
"
:disabled="disabled || workflowActivate.updatingWorkflowActivation.value"
:disabled="
disabled ||
workflowActivate.updatingWorkflowActivation.value ||
(!isNewWorkflow && !workflowPermissions.update)
"
:active-color="getActiveColor"
inactive-color="#8899AA"
data-test-id="workflow-activate-switch"

View file

@ -10,7 +10,7 @@ import {
} from '@/constants';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { getWorkflowPermissions } from '@/permissions';
import { getResourcePermissions } from '@/permissions';
import dateformat from 'dateformat';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { useUIStore } from '@/stores/ui.store';
@ -75,7 +75,7 @@ const projectsStore = useProjectsStore();
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
const workflowPermissions = computed(() => getWorkflowPermissions(props.data));
const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow);
const actions = computed(() => {
const items = [
{
@ -88,7 +88,7 @@ const actions = computed(() => {
},
];
if (!props.readOnly) {
if (workflowPermissions.value.create && !props.readOnly) {
items.push({
label: locale.baseText('workflows.item.duplicate'),
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
@ -274,6 +274,7 @@ function moveResource() {
class="mr-s"
:workflow-active="data.active"
:workflow-id="data.id"
:workflow-permissions="workflowPermissions"
data-test-id="workflow-card-activator"
/>

View file

@ -16,6 +16,8 @@ import { EnterpriseEditionFeature, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constan
import { nextTick } from 'vue';
import type { IWorkflowDb } from '@/Interface';
import * as permissions from '@/permissions';
import type { PermissionsRecord } from '@/permissions';
let pinia: ReturnType<typeof createPinia>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
@ -52,6 +54,11 @@ describe('WorkflowSettingsVue', () => {
updatedAt: 1,
versionId: '123',
} as IWorkflowDb);
vi.spyOn(permissions, 'getResourcePermissions').mockReturnValue({
workflow: {
update: true,
},
} as PermissionsRecord);
uiStore.modalsById[WORKFLOW_SETTINGS_MODAL_KEY] = {
open: true,

View file

@ -23,7 +23,7 @@
placeholder="Select Execution Order"
size="medium"
filterable
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:limit-popper-width="true"
data-test-id="workflow-settings-execution-order"
>
@ -53,7 +53,7 @@
v-model="workflowSettings.errorWorkflow"
placeholder="Select Workflow"
filterable
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:limit-popper-width="true"
data-test-id="workflow-settings-error-workflow"
>
@ -82,7 +82,7 @@
<el-col :span="14" class="ignore-key-press">
<n8n-select
v-model="workflowSettings.callerPolicy"
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:placeholder="$locale.baseText('workflowSettings.selectOption')"
filterable
:limit-popper-width="true"
@ -110,7 +110,7 @@
<el-col :span="14">
<n8n-input
v-model="workflowSettings.callerIds"
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:placeholder="$locale.baseText('workflowSettings.callerIds.placeholder')"
type="text"
data-test-id="workflow-caller-policy-workflow-ids"
@ -134,7 +134,7 @@
v-model="workflowSettings.timezone"
placeholder="Select Timezone"
filterable
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:limit-popper-width="true"
data-test-id="workflow-settings-timezone"
>
@ -163,7 +163,7 @@
v-model="workflowSettings.saveDataErrorExecution"
:placeholder="$locale.baseText('workflowSettings.selectOption')"
filterable
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:limit-popper-width="true"
data-test-id="workflow-settings-save-failed-executions"
>
@ -192,7 +192,7 @@
v-model="workflowSettings.saveDataSuccessExecution"
:placeholder="$locale.baseText('workflowSettings.selectOption')"
filterable
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:limit-popper-width="true"
data-test-id="workflow-settings-save-success-executions"
>
@ -221,7 +221,7 @@
v-model="workflowSettings.saveManualExecutions"
:placeholder="$locale.baseText('workflowSettings.selectOption')"
filterable
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:limit-popper-width="true"
data-test-id="workflow-settings-save-manual-executions"
>
@ -250,7 +250,7 @@
v-model="workflowSettings.saveExecutionProgress"
:placeholder="$locale.baseText('workflowSettings.selectOption')"
filterable
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:limit-popper-width="true"
data-test-id="workflow-settings-save-execution-progress"
>
@ -278,7 +278,7 @@
<div>
<el-switch
ref="inputField"
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:model-value="(workflowSettings.executionTimeout ?? -1) > -1"
active-color="#13ce66"
data-test-id="workflow-settings-timeout-workflow"
@ -303,7 +303,7 @@
</el-col>
<el-col :span="4">
<n8n-input
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:model-value="timeoutHMS.hours"
:min="0"
@update:model-value="(value: string) => setTimeout('hours', value)"
@ -313,7 +313,7 @@
</el-col>
<el-col :span="4" class="timeout-input">
<n8n-input
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:model-value="timeoutHMS.minutes"
:min="0"
:max="60"
@ -324,7 +324,7 @@
</el-col>
<el-col :span="4" class="timeout-input">
<n8n-input
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:model-value="timeoutHMS.seconds"
:min="0"
:max="60"
@ -340,7 +340,7 @@
<template #footer>
<div class="action-buttons" data-test-id="workflow-settings-save-button">
<n8n-button
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !workflowPermissions.update"
:label="$locale.baseText('workflowSettings.save')"
size="large"
float="right"
@ -379,12 +379,10 @@ import { useRootStore } from '@/stores/root.store';
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createEventBus } from 'n8n-design-system/utils';
import type { PermissionsMap } from '@/permissions';
import type { WorkflowScope } from '@n8n/permissions';
import { getWorkflowPermissions } from '@/permissions';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { ProjectTypes } from '@/types/projects.types';
import { getResourcePermissions } from '@/permissions';
export default defineComponent({
name: 'WorkflowSettings',
@ -489,8 +487,8 @@ export default defineComponent({
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflowId}`, fallback);
},
workflowPermissions(): PermissionsMap<WorkflowScope> {
return getWorkflowPermissions(this.workflow);
workflowPermissions() {
return getResourcePermissions(this.workflow?.scopes).workflow;
},
},
async mounted() {
@ -515,17 +513,17 @@ export default defineComponent({
this.defaultValues.workflowCallerPolicy = this.settingsStore.workflowCallerPolicyDefaultOption;
this.isLoading = true;
const promises = [];
promises.push(this.loadWorkflows());
promises.push(this.loadSaveDataErrorExecutionOptions());
promises.push(this.loadSaveDataSuccessExecutionOptions());
promises.push(this.loadSaveExecutionProgressOptions());
promises.push(this.loadSaveManualOptions());
promises.push(this.loadTimezones());
promises.push(this.loadWorkflowCallerPolicyOptions());
try {
await Promise.all(promises);
await Promise.all([
this.loadWorkflows(),
this.loadSaveDataErrorExecutionOptions(),
this.loadSaveDataSuccessExecutionOptions(),
this.loadSaveExecutionProgressOptions(),
this.loadSaveManualOptions(),
this.loadTimezones(),
this.loadWorkflowCallerPolicyOptions(),
]);
} catch (error) {
this.showError(
error,

View file

@ -130,9 +130,7 @@ import {
WORKFLOW_SHARE_MODAL_KEY,
} from '@/constants';
import type { IUser, IWorkflowDb } from '@/Interface';
import type { PermissionsMap } from '@/permissions';
import type { WorkflowScope } from '@n8n/permissions';
import { getWorkflowPermissions } from '@/permissions';
import { getResourcePermissions } from '@/permissions';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { nodeViewEventBus } from '@/event-bus';
@ -224,8 +222,8 @@ export default defineComponent({
currentUser(): IUser | null {
return this.usersStore.currentUser;
},
workflowPermissions(): PermissionsMap<WorkflowScope> {
return getWorkflowPermissions(this.workflow);
workflowPermissions() {
return getResourcePermissions(this.workflow?.scopes).workflow;
},
workflowOwnerName(): string {
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflow.id}`);

View file

@ -100,6 +100,9 @@ describe('GlobalExecutionsList', () => {
const { queryAllByTestId, queryByTestId, getByTestId } = renderComponent({
props: {
executions: [],
filters: {},
total: 0,
estimated: false,
},
pinia,
});
@ -121,6 +124,8 @@ describe('GlobalExecutionsList', () => {
executions: executionsData[0].results,
total: executionsData[0].count,
filteredExecutions: executionsData[0].results,
filters: {},
estimated: false,
},
pinia,
});
@ -185,6 +190,8 @@ describe('GlobalExecutionsList', () => {
executions: executionsData[0].results,
total: executionsData[0].count,
filteredExecutions: executionsData[0].results,
filters: {},
estimated: false,
},
pinia,
});

View file

@ -11,6 +11,8 @@ import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
import type { ExecutionSummary } from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExecutionsStore } from '@/stores/executions.store';
import type { PermissionsRecord } from '@/permissions';
import { getResourcePermissions } from '@/permissions';
const props = withDefaults(
defineProps<{
@ -162,6 +164,12 @@ function getExecutionWorkflowName(execution: ExecutionSummary): string {
);
}
function getExecutionWorkflowPermissions(
execution: ExecutionSummary,
): PermissionsRecord['workflow'] {
return getResourcePermissions(execution.scopes).workflow;
}
function getWorkflowName(workflowId: string): string | undefined {
return workflows.value.find((data: IWorkflowDb) => data.id === workflowId)?.name;
}
@ -344,7 +352,9 @@ async function onAutoRefreshToggle(value: boolean) {
:key="execution.id"
:execution="execution"
:workflow-name="getExecutionWorkflowName(execution)"
:workflow-permissions="getExecutionWorkflowPermissions(execution)"
:selected="selectedItems[execution.id] || allExistingSelected"
data-test-id="global-execution-list-item"
@stop="stopExecution"
@delete="deleteExecution"
@select="toggleSelectExecution"

View file

@ -55,6 +55,9 @@ describe('GlobalExecutionsListItem', () => {
retrySuccessfulId: undefined,
waitTill: false,
},
workflowPermissions: {
execute: true,
},
},
});
@ -73,6 +76,9 @@ describe('GlobalExecutionsListItem', () => {
id: 123,
stoppedAt: undefined,
},
workflowPermissions: {
update: true,
},
},
});
@ -84,7 +90,10 @@ describe('GlobalExecutionsListItem', () => {
global.window.open = vi.fn();
const { getByText } = renderComponent({
props: { execution: { status: 'success', id: 123, workflowName: 'TestWorkflow' } },
props: {
execution: { status: 'success', id: 123, workflowName: 'TestWorkflow' },
workflowPermissions: {},
},
});
await fireEvent.click(getByText('TestWorkflow'));
@ -94,7 +103,10 @@ describe('GlobalExecutionsListItem', () => {
it('should show formatted start date', () => {
const testDate = '2022-01-01T12:00:00Z';
const { getByText } = renderComponent({
props: { execution: { status: 'success', id: 123, startedAt: testDate } },
props: {
execution: { status: 'success', id: 123, startedAt: testDate },
workflowPermissions: {},
},
});
expect(

View file

@ -8,6 +8,7 @@ import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
import { i18n as locale } from '@/plugins/i18n';
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import type { PermissionsRecord } from '@/permissions';
type Command = 'retrySaved' | 'retryOriginal' | 'delete';
@ -24,9 +25,11 @@ const props = withDefaults(
execution: ExecutionSummary;
selected?: boolean;
workflowName?: string;
workflowPermissions: PermissionsRecord['workflow'];
}>(),
{
selected: false,
workflowName: '',
},
);
@ -266,6 +269,7 @@ async function handleActionItemClick(commandData: Command) {
data-test-id="execution-retry-saved-dropdown-item"
:class="$style.retryAction"
command="retrySaved"
:disabled="!workflowPermissions.execute"
>
{{ i18n.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
</ElDropdownItem>
@ -274,6 +278,7 @@ async function handleActionItemClick(commandData: Command) {
data-test-id="execution-retry-original-dropdown-item"
:class="$style.retryAction"
command="retryOriginal"
:disabled="!workflowPermissions.execute"
>
{{ i18n.baseText('executionsList.retryWithOriginalWorkflow') }}
</ElDropdownItem>
@ -281,6 +286,7 @@ async function handleActionItemClick(commandData: Command) {
data-test-id="execution-delete-dropdown-item"
:class="$style.deleteAction"
command="delete"
:disabled="!workflowPermissions.update"
>
{{ i18n.baseText('generic.delete') }}
</ElDropdownItem>

View file

@ -2,6 +2,13 @@ import { createComponentRenderer } from '@/__tests__/render';
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
import { createPinia, setActivePinia } from 'pinia';
vi.mock('vue-router', () => ({
useRoute: () => ({
params: {},
}),
RouterLink: vi.fn(),
}));
const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
global: {
stubs: {
@ -25,53 +32,102 @@ describe('WorkflowExecutionsCard', () => {
test.each([
[
{
id: '1',
mode: 'manual',
status: 'success',
retryOf: null,
retrySuccessId: null,
execution: {
id: '1',
mode: 'manual',
status: 'success',
retryOf: null,
retrySuccessId: null,
},
workflowPermissions: {
execute: true,
},
},
false,
false,
],
[
{
id: '2',
mode: 'manual',
status: 'error',
retryOf: null,
retrySuccessId: null,
execution: {
id: '2',
mode: 'manual',
status: 'error',
retryOf: null,
retrySuccessId: null,
},
workflowPermissions: {
execute: true,
},
},
true,
],
[
{
id: '3',
mode: 'manual',
status: 'error',
retryOf: '2',
retrySuccessId: null,
},
true,
],
[
{
id: '4',
mode: 'manual',
status: 'error',
retryOf: null,
retrySuccessId: '3',
},
false,
],
])('with execution %j retry button visibility is %s', (execution, shouldRenderRetryBtn) => {
const { queryByTestId } = renderComponent({
props: {
execution,
[
{
execution: {
id: '3',
mode: 'manual',
status: 'error',
retryOf: null,
retrySuccessId: '3',
},
workflowPermissions: {
execute: true,
},
},
});
false,
false,
],
[
{
execution: {
id: '4',
mode: 'manual',
status: 'success',
retryOf: '4',
retrySuccessId: null,
},
workflowPermissions: {
execute: true,
},
},
false,
false,
],
[
{
execution: {
id: '2',
mode: 'manual',
status: 'error',
retryOf: null,
retrySuccessId: null,
},
workflowPermissions: {},
},
true,
true,
],
])(
'with execution %j retry button visibility is %s and if visible is disabled %s',
(props, shouldRenderRetryBtn, disabled) => {
const { queryByTestId } = renderComponent({
props,
});
expect(!!queryByTestId('retry-execution-button') && shouldRenderRetryBtn).toBe(
shouldRenderRetryBtn,
);
});
const retryButton = queryByTestId('retry-execution-button');
if (shouldRenderRetryBtn) {
expect(retryButton).toBeVisible();
if (disabled) {
expect(retryButton?.querySelector('.is-disabled')).toBeVisible();
} else {
expect(retryButton?.querySelector('.is-disabled')).toBe(null);
}
} else {
expect(retryButton).toBe(null);
}
},
);
});

View file

@ -1,3 +1,59 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { VIEWS } from '@/constants';
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import type { ExecutionSummary } from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useI18n } from '@/composables/useI18n';
import type { PermissionsRecord } from '@/permissions';
const props = defineProps<{
execution: ExecutionSummary;
highlight?: boolean;
showGap?: boolean;
workflowPermissions: PermissionsRecord['workflow'];
}>();
const emit = defineEmits<{
retryExecution: [{ execution: ExecutionSummary; command: string }];
mounted: [string];
}>();
const route = useRoute();
const locale = useI18n();
const executionHelpers = useExecutionHelpers();
const workflowsStore = useWorkflowsStore();
const currentWorkflow = computed(() => (route.params.name as string) || workflowsStore.workflowId);
const retryExecutionActions = computed(() => [
{
id: 'current-workflow',
label: locale.baseText('executionsList.retryWithCurrentlySavedWorkflow'),
},
{
id: 'original-workflow',
label: locale.baseText('executionsList.retryWithOriginalWorkflow'),
},
]);
const executionUIDetails = computed<IExecutionUIData>(() =>
executionHelpers.getUIDetails(props.execution),
);
const isActive = computed(() => props.execution.id === route.params.executionId);
const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.execution));
onMounted(() => {
emit('mounted', props.execution.id);
});
function onRetryMenuItemSelect(action: string): void {
emit('retryExecution', { execution: props.execution, command: action });
}
</script>
<template>
<div
:class="{
@ -12,153 +68,74 @@
<router-link
:class="$style.executionLink"
:to="{
name: executionPreviewViewName,
name: VIEWS.EXECUTION_PREVIEW,
params: { name: currentWorkflow, executionId: execution.id },
}"
:data-test-execution-status="executionUIDetails.name"
>
<div :class="$style.description">
<n8n-text color="text-dark" :bold="true" size="medium" data-test-id="execution-time">
<N8nText color="text-dark" :bold="true" size="medium" data-test-id="execution-time">
{{ executionUIDetails.startTime }}
</n8n-text>
</N8nText>
<div :class="$style.executionStatus">
<n8n-spinner
<N8nSpinner
v-if="executionUIDetails.name === 'running'"
size="small"
:class="[$style.spinner, 'mr-4xs']"
/>
<n8n-text :class="$style.statusLabel" size="small">{{
executionUIDetails.label
}}</n8n-text>
<N8nText :class="$style.statusLabel" size="small">{{ executionUIDetails.label }}</N8nText>
{{ ' ' }}
<n8n-text
<N8nText
v-if="executionUIDetails.name === 'running'"
:color="isActive ? 'text-dark' : 'text-base'"
size="small"
>
{{ $locale.baseText('executionDetails.runningTimeRunning') }}
{{ locale.baseText('executionDetails.runningTimeRunning') }}
<ExecutionsTime :start-time="execution.startedAt" />
</n8n-text>
<n8n-text
</N8nText>
<N8nText
v-else-if="executionUIDetails.runningTime !== ''"
:color="isActive ? 'text-dark' : 'text-base'"
size="small"
>
{{
$locale.baseText('executionDetails.runningTimeFinished', {
locale.baseText('executionDetails.runningTimeFinished', {
interpolate: { time: executionUIDetails?.runningTime },
})
}}
</n8n-text>
</N8nText>
</div>
<div v-if="execution.mode === 'retry'">
<n8n-text :color="isActive ? 'text-dark' : 'text-base'" size="small">
{{ $locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
</n8n-text>
<N8nText :color="isActive ? 'text-dark' : 'text-base'" size="small">
{{ locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
</N8nText>
</div>
</div>
<div :class="$style.icons">
<n8n-action-dropdown
<N8nActionDropdown
v-if="isRetriable"
:class="[$style.icon, $style.retry]"
:items="retryExecutionActions"
:disabled="!workflowPermissions.execute"
activator-icon="redo"
data-test-id="retry-execution-button"
@select="onRetryMenuItemSelect"
/>
<n8n-tooltip v-if="execution.mode === 'manual'" placement="top">
<N8nTooltip v-if="execution.mode === 'manual'" placement="top">
<template #content>
<span>{{ $locale.baseText('executionsList.test') }}</span>
<span>{{ locale.baseText('executionsList.test') }}</span>
</template>
<font-awesome-icon
<FontAwesomeIcon
v-if="execution.mode === 'manual'"
:class="[$style.icon, $style.manual]"
icon="flask"
/>
</n8n-tooltip>
</N8nTooltip>
</div>
</router-link>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { VIEWS } from '@/constants';
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import type { ExecutionSummary } from 'n8n-workflow';
import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows.store';
export default defineComponent({
name: 'WorkflowExecutionsCard',
components: {
ExecutionsTime,
},
props: {
execution: {
type: Object as () => ExecutionSummary,
required: true,
},
highlight: {
type: Boolean,
default: false,
},
showGap: {
type: Boolean,
default: false,
},
},
emits: ['retryExecution', 'mounted'],
setup() {
const executionHelpers = useExecutionHelpers();
return {
executionHelpers,
};
},
computed: {
...mapStores(useWorkflowsStore),
currentWorkflow(): string {
return (this.$route.params.name as string) || this.workflowsStore.workflowId;
},
retryExecutionActions(): object[] {
return [
{
id: 'current-workflow',
label: this.$locale.baseText('executionsList.retryWithCurrentlySavedWorkflow'),
},
{
id: 'original-workflow',
label: this.$locale.baseText('executionsList.retryWithOriginalWorkflow'),
},
];
},
executionUIDetails(): IExecutionUIData {
return this.executionHelpers.getUIDetails(this.execution);
},
isActive(): boolean {
return this.execution.id === this.$route.params.executionId;
},
isRetriable(): boolean {
return this.executionHelpers.isExecutionRetriable(this.execution);
},
executionPreviewViewName() {
return VIEWS.EXECUTION_PREVIEW;
},
},
mounted() {
this.$emit('mounted', this.execution.id);
},
methods: {
onRetryMenuItemSelect(action: string): void {
this.$emit('retryExecution', { execution: this.execution, command: action });
},
},
});
</script>
<style module lang="scss">
@import '@/styles/variables';

View file

@ -1,5 +1,194 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import type { IWorkflowSettings } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { useI18n } from '@/composables/useI18n';
interface IWorkflowSaveSettings {
saveFailedExecutions: boolean;
saveSuccessfulExecutions: boolean;
saveTestExecutions: boolean;
}
const props = withDefaults(
defineProps<{
initiallyExpanded: boolean;
}>(),
{
initiallyExpanded: false,
},
);
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
const locale = useI18n();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const npsSurveyStore = useNpsSurveyStore();
const defaultValues = ref({
saveFailedExecutions: 'all',
saveSuccessfulExecutions: 'all',
saveManualExecutions: false,
});
const workflowSaveSettings = ref({
saveFailedExecutions: false,
saveSuccessfulExecutions: false,
saveTestExecutions: false,
} as IWorkflowSaveSettings);
const accordionItems = computed(() => [
{
id: 'productionExecutions',
label: locale.baseText('executionsLandingPage.emptyState.accordion.productionExecutions'),
icon: productionExecutionsIcon.value.icon,
iconColor: productionExecutionsIcon.value.color,
tooltip:
productionExecutionsStatus.value === 'unknown'
? locale.baseText(
'executionsLandingPage.emptyState.accordion.productionExecutionsWarningTooltip',
)
: null,
},
{
id: 'manualExecutions',
label: locale.baseText('executionsLandingPage.emptyState.accordion.testExecutions'),
icon: workflowSaveSettings.value.saveTestExecutions ? 'check' : 'times',
iconColor: workflowSaveSettings.value.saveTestExecutions ? 'success' : 'danger',
},
]);
const shouldExpandAccordion = computed(() => {
if (!props.initiallyExpanded) {
return false;
}
return (
!workflowSaveSettings.value.saveFailedExecutions ||
!workflowSaveSettings.value.saveSuccessfulExecutions ||
!workflowSaveSettings.value.saveTestExecutions
);
});
const productionExecutionsIcon = computed(() => {
if (productionExecutionsStatus.value === 'saving') {
return { icon: 'check', color: 'success' };
} else if (productionExecutionsStatus.value === 'not-saving') {
return { icon: 'times', color: 'danger' };
}
return { icon: 'exclamation-triangle', color: 'warning' };
});
const productionExecutionsStatus = computed(() => {
if (
workflowSaveSettings.value.saveSuccessfulExecutions ===
workflowSaveSettings.value.saveFailedExecutions
) {
if (workflowSaveSettings.value.saveSuccessfulExecutions) {
return 'saving';
}
return 'not-saving';
} else {
return 'unknown';
}
});
const workflowSettings = computed(() => deepCopy(workflowsStore.workflowSettings));
const accordionIcon = computed(() => {
if (
!workflowSaveSettings.value.saveTestExecutions ||
productionExecutionsStatus.value !== 'saving'
) {
return { icon: 'exclamation-triangle', color: 'warning' };
}
return null;
});
const currentWorkflowId = computed(() => workflowsStore.workflowId);
const isNewWorkflow = computed(() => {
return (
!currentWorkflowId.value ||
currentWorkflowId.value === PLACEHOLDER_EMPTY_WORKFLOW_ID ||
currentWorkflowId.value === 'new'
);
});
const workflowName = computed(() => workflowsStore.workflowName);
const currentWorkflowTagIds = computed(() => workflowsStore.workflowTags);
watch(workflowSettings, (newSettings: IWorkflowSettings) => {
updateSettings(newSettings);
});
onMounted(() => {
defaultValues.value.saveFailedExecutions = settingsStore.saveDataErrorExecution;
defaultValues.value.saveSuccessfulExecutions = settingsStore.saveDataSuccessExecution;
defaultValues.value.saveManualExecutions = settingsStore.saveManualExecutions;
updateSettings(workflowSettings.value);
});
function updateSettings(wfSettings: IWorkflowSettings): void {
workflowSaveSettings.value.saveFailedExecutions =
wfSettings.saveDataErrorExecution === undefined
? defaultValues.value.saveFailedExecutions === 'all'
: wfSettings.saveDataErrorExecution === 'all';
workflowSaveSettings.value.saveSuccessfulExecutions =
wfSettings.saveDataSuccessExecution === undefined
? defaultValues.value.saveSuccessfulExecutions === 'all'
: wfSettings.saveDataSuccessExecution === 'all';
workflowSaveSettings.value.saveTestExecutions =
wfSettings.saveManualExecutions === undefined
? defaultValues.value.saveManualExecutions
: (wfSettings.saveManualExecutions as boolean);
}
function onAccordionClick(event: MouseEvent): void {
if (event.target instanceof HTMLAnchorElement) {
event.preventDefault();
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
}
}
function onItemTooltipClick(item: string, event: MouseEvent): void {
if (item === 'productionExecutions' && event.target instanceof HTMLAnchorElement) {
event.preventDefault();
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
}
}
function openWorkflowSettings(): void {
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
}
async function onSaveWorkflowClick(): Promise<void> {
let currentId: string | undefined = undefined;
if (currentWorkflowId.value !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
currentId = currentWorkflowId.value;
} else if (
router.currentRoute.value.params.name &&
router.currentRoute.value.params.name !== 'new'
) {
const routeName = router.currentRoute.value.params.name;
currentId = Array.isArray(routeName) ? routeName[0] : routeName;
}
if (!currentId) {
return;
}
const saved = await workflowHelpers.saveCurrentWorkflow({
id: currentId,
name: workflowName.value,
tags: currentWorkflowTagIds.value,
});
if (saved) {
await npsSurveyStore.fetchPromptsData();
}
}
</script>
<template>
<n8n-info-accordion
<N8nInfoAccordion
:class="[$style.accordion, 'mt-2xl']"
:title="$locale.baseText('executionsLandingPage.emptyState.accordion.title')"
:items="accordionItems"
@ -11,232 +200,30 @@
<template #customContent>
<footer class="mt-2xs">
{{ $locale.baseText('executionsLandingPage.emptyState.accordion.footer') }}
<n8n-tooltip :disabled="!isNewWorkflow">
<N8nTooltip :disabled="!isNewWorkflow">
<template #content>
<div>
<n8n-link @click.prevent="onSaveWorkflowClick">{{
<N8nLink @click.prevent="onSaveWorkflowClick">{{
$locale.baseText('executionsLandingPage.emptyState.accordion.footer.tooltipLink')
}}</n8n-link>
}}</N8nLink>
{{
$locale.baseText('executionsLandingPage.emptyState.accordion.footer.tooltipText')
}}
</div>
</template>
<n8n-link
<N8nLink
:class="{ [$style.disabled]: isNewWorkflow }"
size="small"
@click.prevent="openWorkflowSettings"
>
{{ $locale.baseText('executionsLandingPage.emptyState.accordion.footer.settingsLink') }}
</n8n-link>
</n8n-tooltip>
</N8nLink>
</N8nTooltip>
</footer>
</template>
</n8n-info-accordion>
</N8nInfoAccordion>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import { useRouter } from 'vue-router';
import { useRootStore } from '@/stores/root.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import type { IWorkflowSettings } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
interface IWorkflowSaveSettings {
saveFailedExecutions: boolean;
saveSuccessfulExecutions: boolean;
saveTestExecutions: boolean;
}
export default defineComponent({
name: 'WorkflowExecutionsInfoAccordion',
props: {
initiallyExpanded: {
type: Boolean,
default: false,
},
},
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
return {
workflowHelpers,
};
},
data() {
return {
defaultValues: {
saveFailedExecutions: 'all',
saveSuccessfulExecutions: 'all',
saveManualExecutions: false,
},
workflowSaveSettings: {
saveFailedExecutions: false,
saveSuccessfulExecutions: false,
saveTestExecutions: false,
} as IWorkflowSaveSettings,
};
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUIStore, useWorkflowsStore, useNpsSurveyStore),
accordionItems(): object[] {
return [
{
id: 'productionExecutions',
label: this.$locale.baseText(
'executionsLandingPage.emptyState.accordion.productionExecutions',
),
icon: this.productionExecutionsIcon.icon,
iconColor: this.productionExecutionsIcon.color,
tooltip:
this.productionExecutionsStatus === 'unknown'
? this.$locale.baseText(
'executionsLandingPage.emptyState.accordion.productionExecutionsWarningTooltip',
)
: null,
},
{
id: 'manualExecutions',
label: this.$locale.baseText('executionsLandingPage.emptyState.accordion.testExecutions'),
icon: this.workflowSaveSettings.saveTestExecutions ? 'check' : 'times',
iconColor: this.workflowSaveSettings.saveTestExecutions ? 'success' : 'danger',
},
];
},
shouldExpandAccordion(): boolean {
if (!this.initiallyExpanded) {
return false;
}
return (
!this.workflowSaveSettings.saveFailedExecutions ||
!this.workflowSaveSettings.saveSuccessfulExecutions ||
!this.workflowSaveSettings.saveTestExecutions
);
},
productionExecutionsIcon(): { icon: string; color: string } {
if (this.productionExecutionsStatus === 'saving') {
return { icon: 'check', color: 'success' };
} else if (this.productionExecutionsStatus === 'not-saving') {
return { icon: 'times', color: 'danger' };
}
return { icon: 'exclamation-triangle', color: 'warning' };
},
productionExecutionsStatus(): string {
if (
this.workflowSaveSettings.saveSuccessfulExecutions ===
this.workflowSaveSettings.saveFailedExecutions
) {
if (this.workflowSaveSettings.saveSuccessfulExecutions) {
return 'saving';
}
return 'not-saving';
} else {
return 'unknown';
}
},
workflowSettings(): IWorkflowSettings {
const workflowSettings = deepCopy(this.workflowsStore.workflowSettings);
return workflowSettings;
},
accordionIcon(): { icon: string; color: string } | null {
if (
!this.workflowSaveSettings.saveTestExecutions ||
this.productionExecutionsStatus !== 'saving'
) {
return { icon: 'exclamation-triangle', color: 'warning' };
}
return null;
},
currentWorkflowId(): string {
return this.workflowsStore.workflowId;
},
isNewWorkflow(): boolean {
return (
!this.currentWorkflowId ||
this.currentWorkflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID ||
this.currentWorkflowId === 'new'
);
},
workflowName(): string {
return this.workflowsStore.workflowName;
},
currentWorkflowTagIds(): string[] {
return this.workflowsStore.workflowTags;
},
},
watch: {
workflowSettings(newSettings: IWorkflowSettings) {
this.updateSettings(newSettings);
},
},
mounted() {
this.defaultValues.saveFailedExecutions = this.settingsStore.saveDataErrorExecution;
this.defaultValues.saveSuccessfulExecutions = this.settingsStore.saveDataSuccessExecution;
this.defaultValues.saveManualExecutions = this.settingsStore.saveManualExecutions;
this.updateSettings(this.workflowSettings);
},
methods: {
updateSettings(workflowSettings: IWorkflowSettings): void {
this.workflowSaveSettings.saveFailedExecutions =
workflowSettings.saveDataErrorExecution === undefined
? this.defaultValues.saveFailedExecutions === 'all'
: workflowSettings.saveDataErrorExecution === 'all';
this.workflowSaveSettings.saveSuccessfulExecutions =
workflowSettings.saveDataSuccessExecution === undefined
? this.defaultValues.saveSuccessfulExecutions === 'all'
: workflowSettings.saveDataSuccessExecution === 'all';
this.workflowSaveSettings.saveTestExecutions =
workflowSettings.saveManualExecutions === undefined
? this.defaultValues.saveManualExecutions
: (workflowSettings.saveManualExecutions as boolean);
},
onAccordionClick(event: MouseEvent): void {
if (event.target instanceof HTMLAnchorElement) {
event.preventDefault();
this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
}
},
onItemTooltipClick(item: string, event: MouseEvent): void {
if (item === 'productionExecutions' && event.target instanceof HTMLAnchorElement) {
event.preventDefault();
this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
}
},
openWorkflowSettings(): void {
this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
},
async onSaveWorkflowClick(): Promise<void> {
let currentId: string | undefined = undefined;
if (this.currentWorkflowId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
currentId = this.currentWorkflowId;
} else if (this.$route.params.name && this.$route.params.name !== 'new') {
const routeName = this.$route.params.name;
currentId = Array.isArray(routeName) ? routeName[0] : routeName;
}
if (!currentId) {
return;
}
const saved = await this.workflowHelpers.saveCurrentWorkflow({
id: currentId,
name: this.workflowName,
tags: this.currentWorkflowTagIds,
});
if (saved) {
await this.npsSurveyStore.fetchPromptsData();
}
},
},
});
</script>
<style module lang="scss">
.accordion {
background: none;

View file

@ -1,67 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import WorkflowExecutionsInfoAccordion from './WorkflowExecutionsInfoAccordion.vue';
import { useI18n } from '@/composables/useI18n';
const router = useRouter();
const route = useRoute();
const locale = useI18n();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const executionCount = computed(() => workflowsStore.currentWorkflowExecutions.length);
const containsTrigger = computed(() => workflowsStore.workflowTriggerNodes.length > 0);
function onSetupFirstStep(): void {
uiStore.addFirstStepOnLoad = true;
const workflowRoute = getWorkflowRoute();
void router.push(workflowRoute);
}
function getWorkflowRoute(): { name: string; params: {} } {
const workflowId = workflowsStore.workflowId || route.params.name;
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
return { name: VIEWS.NEW_WORKFLOW, params: {} };
} else {
return { name: VIEWS.WORKFLOW, params: { name: workflowId } };
}
}
</script>
<template>
<div :class="['workflow-executions-container', $style.container]">
<div v-if="executionCount === 0" :class="[$style.messageContainer, $style.noExecutionsMessage]">
<div v-if="!containsTrigger">
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
{{ $locale.baseText('executionsLandingPage.emptyState.noTrigger.heading') }}
</n8n-heading>
<n8n-text size="medium">
{{ $locale.baseText('executionsLandingPage.emptyState.message') }}
</n8n-text>
<n8n-button class="mt-l" type="tertiary" size="large" @click="onSetupFirstStep">
{{ $locale.baseText('executionsLandingPage.emptyState.noTrigger.buttonText') }}
</n8n-button>
<N8nHeading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
{{ locale.baseText('executionsLandingPage.emptyState.noTrigger.heading') }}
</N8nHeading>
<N8nText size="medium">
{{ locale.baseText('executionsLandingPage.emptyState.message') }}
</N8nText>
<N8nButton class="mt-l" type="tertiary" size="large" @click="onSetupFirstStep">
{{ locale.baseText('executionsLandingPage.emptyState.noTrigger.buttonText') }}
</N8nButton>
</div>
<div v-else>
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
{{ $locale.baseText('executionsLandingPage.emptyState.heading') }}
</n8n-heading>
<N8nHeading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
{{ locale.baseText('executionsLandingPage.emptyState.heading') }}
</N8nHeading>
<WorkflowExecutionsInfoAccordion />
</div>
</div>
</div>
</template>
<script lang="ts">
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { mapStores } from 'pinia';
import { defineComponent } from 'vue';
import WorkflowExecutionsInfoAccordion from './WorkflowExecutionsInfoAccordion.vue';
export default defineComponent({
name: 'ExecutionsLandingPage',
components: {
WorkflowExecutionsInfoAccordion,
},
computed: {
...mapStores(useUIStore, useWorkflowsStore),
executionCount(): number {
return this.workflowsStore.currentWorkflowExecutions.length;
},
containsTrigger(): boolean {
return this.workflowsStore.workflowTriggerNodes.length > 0;
},
},
methods: {
onSetupFirstStep(): void {
this.uiStore.addFirstStepOnLoad = true;
const workflowRoute = this.getWorkflowRoute();
void this.$router.push(workflowRoute);
},
getWorkflowRoute(): { name: string; params: {} } {
const workflowId = this.workflowsStore.workflowId || this.$route.params.name;
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
return { name: VIEWS.NEW_WORKFLOW, params: {} };
} else {
return { name: VIEWS.WORKFLOW, params: { name: workflowId } };
}
},
},
});
</script>
<style module lang="scss">
.container {
width: 100%;

View file

@ -1,30 +1,5 @@
<template>
<div :class="$style.container">
<WorkflowExecutionsSidebar
:executions="executions"
:loading="loading && !executions.length"
:loading-more="loadingMore"
:temporary-execution="temporaryExecution"
@update:auto-refresh="emit('update:auto-refresh', $event)"
@reload-executions="emit('reload')"
@filter-updated="emit('update:filters', $event)"
@load-more="emit('load-more')"
@retry-execution="onRetryExecution"
/>
<div v-if="!hidePreview" :class="$style.content">
<router-view
name="executionPreview"
:execution="execution"
@delete-current-execution="onDeleteCurrentExecution"
@retry-execution="onRetryExecution"
@stop-execution="onStopExecution"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, watch } from 'vue';
import { onBeforeRouteLeave, useRouter } from 'vue-router';
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
import { MAIN_HEADER_TABS, VIEWS } from '@/constants';
@ -32,7 +7,6 @@ import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
import type { ExecutionSummary } from 'n8n-workflow';
import { getNodeViewTab } from '@/utils/canvasUtils';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { watch } from 'vue';
const props = withDefaults(
defineProps<{
@ -62,10 +36,11 @@ const emit = defineEmits<{
const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
const router = useRouter();
const temporaryExecution = computed(() => {
const isTemporary = !props.executions.find((execution) => execution.id === props.execution?.id);
return isTemporary ? props.execution : undefined;
});
const temporaryExecution = computed<ExecutionSummary | undefined>(() =>
props.executions.find((execution) => execution.id === props.execution?.id)
? undefined
: props.execution ?? undefined,
);
const hidePreview = computed(() => {
return props.loading || (!props.execution && props.executions.length);
@ -118,6 +93,32 @@ onBeforeRouteLeave(async (to, _, next) => {
});
</script>
<template>
<div :class="$style.container">
<WorkflowExecutionsSidebar
:executions="executions"
:loading="loading && !executions.length"
:loading-more="loadingMore"
:temporary-execution="temporaryExecution"
:workflow="workflow"
@update:auto-refresh="emit('update:auto-refresh', $event)"
@reload-executions="emit('reload')"
@filter-updated="emit('update:filters', $event)"
@load-more="emit('load-more')"
@retry-execution="onRetryExecution"
/>
<div v-if="!hidePreview" :class="$style.content">
<router-view
name="executionPreview"
:execution="execution"
@delete-current-execution="onDeleteCurrentExecution"
@retry-execution="onRetryExecution"
@stop-execution="onStopExecution"
/>
</div>
</div>
</template>
<style module lang="scss">
.container {
display: flex;

View file

@ -11,6 +11,8 @@ import { EnterpriseEditionFeature, VIEWS } from '@/constants';
import { i18nInstance, I18nPlugin } from '@/plugins/i18n';
import { FontAwesomePlugin } from '@/plugins/icons';
import { GlobalComponentsPlugin } from '@/plugins/components';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { IWorkflowDb } from '@/Interface';
let pinia: ReturnType<typeof createPinia>;
@ -59,10 +61,12 @@ const executionDataFactory = (): ExecutionSummary => ({
nodeExecutionStatus: {},
retryOf: generateUndefinedNullOrString(),
retrySuccessId: generateUndefinedNullOrString(),
scopes: ['workflow:update'],
});
describe('WorkflowExecutionsPreview.vue', () => {
let settingsStore: ReturnType<typeof useSettingsStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
const executionData: ExecutionSummary = executionDataFactory();
beforeEach(() => {
@ -70,19 +74,25 @@ describe('WorkflowExecutionsPreview.vue', () => {
setActivePinia(pinia);
settingsStore = useSettingsStore();
workflowsStore = useWorkflowsStore();
});
test.each([
[false, '/'],
[true, `/workflow/${executionData.workflowId}/debug/${executionData.id}`],
[false, [], '/'],
[false, ['workflow:update'], '/'],
[true, [], '/'],
[true, ['workflow:read'], '/'],
[true, ['workflow:update'], `/workflow/${executionData.workflowId}/debug/${executionData.id}`],
])(
'when debug enterprise feature is %s it should handle debug link click accordingly',
async (availability, path) => {
'when debug enterprise feature is %s with workflow scopes %s it should handle debug link click accordingly',
async (availability, scopes, path) => {
settingsStore.settings.enterprise = {
...(settingsStore.settings.enterprise ?? {}),
[EnterpriseEditionFeature.DebugInEditor]: availability,
};
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({ scopes } as IWorkflowDb);
// Not using createComponentRenderer helper here because this component should not stub `router-link`
const { getByTestId } = render(WorkflowExecutionsPreview, {
props: {

View file

@ -1,22 +1,113 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { ElDropdown } from 'element-plus';
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
import { useMessage } from '@/composables/useMessage';
import WorkflowPreview from '@/components/WorkflowPreview.vue';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import type { ExecutionSummary } from 'n8n-workflow';
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useI18n } from '@/composables/useI18n';
import { getResourcePermissions } from '@/permissions';
type RetryDropdownRef = InstanceType<typeof ElDropdown>;
const props = defineProps<{
execution: ExecutionSummary;
}>();
const emit = defineEmits<{
deleteCurrentExecution: [];
retryExecution: Array<{ execution: ExecutionSummary; command: string }>;
stopExecution: [];
}>();
const route = useRoute();
const locale = useI18n();
const executionHelpers = useExecutionHelpers();
const message = useMessage();
const executionDebugging = useExecutionDebugging();
const workflowsStore = useWorkflowsStore();
const retryDropdownRef = ref<RetryDropdownRef | null>(null);
const workflowId = computed(() => route.params.name as string);
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
);
const executionId = computed(() => route.params.executionId as string);
const executionUIDetails = computed<IExecutionUIData | null>(() =>
props.execution ? executionHelpers.getUIDetails(props.execution) : null,
);
const debugButtonData = computed(() =>
props.execution?.status === 'success'
? {
text: locale.baseText('executionsList.debug.button.copyToEditor'),
type: 'secondary',
}
: {
text: locale.baseText('executionsList.debug.button.debugInEditor'),
type: 'primary',
},
);
const isRetriable = computed(
() => !!props.execution && executionHelpers.isExecutionRetriable(props.execution),
);
async function onDeleteExecution(): Promise<void> {
const deleteConfirmed = await message.confirm(
locale.baseText('executionDetails.confirmMessage.message'),
locale.baseText('executionDetails.confirmMessage.headline'),
{
type: 'warning',
confirmButtonText: locale.baseText('executionDetails.confirmMessage.confirmButtonText'),
cancelButtonText: '',
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
emit('deleteCurrentExecution');
}
function handleRetryClick(command: string) {
emit('retryExecution', { execution: props.execution, command });
}
function handleStopClick() {
emit('stopExecution');
}
function onRetryButtonBlur(event: FocusEvent) {
// Hide dropdown when clicking outside of current document
if (retryDropdownRef.value && event.relatedTarget === null) {
retryDropdownRef.value.handleClose();
}
}
</script>
<template>
<div v-if="executionUIDetails?.name === 'new'" :class="$style.newInfo">
<n8n-text :class="$style.newMessage" color="text-light">
{{ $locale.baseText('executionDetails.newMessage') }}
</n8n-text>
<n8n-button class="mt-l" type="tertiary" @click="handleStopClick">
{{ $locale.baseText('executionsList.stopExecution') }}
</n8n-button>
<N8nText :class="$style.newMessage" color="text-light">
{{ locale.baseText('executionDetails.newMessage') }}
</N8nText>
<N8nButton class="mt-l" type="tertiary" @click="handleStopClick">
{{ locale.baseText('executionsList.stopExecution') }}
</N8nButton>
</div>
<div v-else-if="executionUIDetails?.name === 'running'" :class="$style.runningInfo">
<div :class="$style.spinner">
<n8n-spinner type="ring" />
<N8nSpinner type="ring" />
</div>
<n8n-text :class="$style.runningMessage" color="text-light">
{{ $locale.baseText('executionDetails.runningMessage') }}
</n8n-text>
<n8n-button class="mt-l" type="tertiary" @click="handleStopClick">
{{ $locale.baseText('executionsList.stopExecution') }}
</n8n-button>
<N8nText :class="$style.runningMessage" color="text-light">
{{ locale.baseText('executionDetails.runningMessage') }}
</N8nText>
<N8nButton class="mt-l" type="tertiary" @click="handleStopClick">
{{ locale.baseText('executionsList.stopExecution') }}
</N8nButton>
</div>
<div v-else-if="executionUIDetails" :class="$style.previewContainer">
<div
@ -25,57 +116,53 @@
:data-test-id="`execution-preview-details-${executionId}`"
>
<div>
<n8n-text size="large" color="text-base" :bold="true" data-test-id="execution-time">{{
<N8nText size="large" color="text-base" :bold="true" data-test-id="execution-time">{{
executionUIDetails?.startTime
}}</n8n-text
}}</N8nText
><br />
<n8n-spinner
<N8nSpinner
v-if="executionUIDetails?.name === 'running'"
size="small"
:class="[$style.spinner, 'mr-4xs']"
/>
<n8n-text
<N8nText
size="medium"
:class="[$style.status, $style[executionUIDetails.name]]"
data-test-id="execution-preview-label"
>
{{ executionUIDetails.label }}
</n8n-text>
</N8nText>
{{ ' ' }}
<n8n-text
v-if="executionUIDetails?.showTimestamp === false"
color="text-base"
size="medium"
>
<N8nText v-if="executionUIDetails?.showTimestamp === false" color="text-base" size="medium">
| ID#{{ execution.id }}
</n8n-text>
<n8n-text v-else-if="executionUIDetails.name === 'running'" color="text-base" size="medium">
</N8nText>
<N8nText v-else-if="executionUIDetails.name === 'running'" color="text-base" size="medium">
{{
$locale.baseText('executionDetails.runningTimeRunning', {
locale.baseText('executionDetails.runningTimeRunning', {
interpolate: { time: executionUIDetails?.runningTime },
})
}}
| ID#{{ execution.id }}
</n8n-text>
<n8n-text
</N8nText>
<N8nText
v-else-if="executionUIDetails.name !== 'waiting'"
color="text-base"
size="medium"
data-test-id="execution-preview-id"
>
{{
$locale.baseText('executionDetails.runningTimeFinished', {
locale.baseText('executionDetails.runningTimeFinished', {
interpolate: { time: executionUIDetails?.runningTime ?? 'unknown' },
})
}}
| ID#{{ execution.id }}
</n8n-text>
<br /><n8n-text v-if="execution.mode === 'retry'" color="text-base" size="medium">
{{ $locale.baseText('executionDetails.retry') }}
</N8nText>
<br /><N8nText v-if="execution.mode === 'retry'" color="text-base" size="medium">
{{ locale.baseText('executionDetails.retry') }}
<router-link
:class="$style.executionLink"
:to="{
name: executionPreviewViewName,
name: VIEWS.EXECUTION_PREVIEW,
params: {
workflowId: execution.workflowId,
executionId: execution.retryOf,
@ -84,24 +171,31 @@
>
#{{ execution.retryOf }}
</router-link>
</n8n-text>
</N8nText>
</div>
<div>
<n8n-button size="medium" :type="debugButtonData.type" :class="$style.debugLink">
<router-link
:to="{
name: executionDebugViewName,
params: {
name: execution.workflowId,
executionId: execution.id,
},
}"
<router-link
:to="{
name: VIEWS.EXECUTION_DEBUG,
params: {
name: execution.workflowId,
executionId: execution.id,
},
}"
>
<N8nButton
size="medium"
:type="debugButtonData.type"
:class="$style.debugLink"
:disabled="!workflowPermissions.update"
>
<span data-test-id="execution-debug-button" @click="handleDebugLinkClick">{{
debugButtonData.text
}}</span>
</router-link>
</n8n-button>
<span
data-test-id="execution-debug-button"
@click="executionDebugging.handleDebugLinkClick"
>{{ debugButtonData.text }}</span
>
</N8nButton>
</router-link>
<ElDropdown
v-if="isRetriable"
@ -111,28 +205,30 @@
@command="handleRetryClick"
>
<span class="retry-button">
<n8n-icon-button
<N8nIconButton
size="medium"
type="tertiary"
:title="$locale.baseText('executionsList.retryExecution')"
:title="locale.baseText('executionsList.retryExecution')"
:disabled="!workflowPermissions.update"
icon="redo"
data-test-id="execution-preview-retry-button"
@blur="onRetryButtonBlur"
/>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="current-workflow">
{{ $locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
</el-dropdown-item>
<el-dropdown-item command="original-workflow">
{{ $locale.baseText('executionsList.retryWithOriginalWorkflow') }}
</el-dropdown-item>
</el-dropdown-menu>
<ElDropdownMenu>
<ElDropdownItem command="current-workflow">
{{ locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
</ElDropdownItem>
<ElDropdownItem command="original-workflow">
{{ locale.baseText('executionsList.retryWithOriginalWorkflow') }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<n8n-icon-button
:title="$locale.baseText('executionDetails.deleteExecution')"
<N8nIconButton
:title="locale.baseText('executionDetails.deleteExecution')"
:disabled="!workflowPermissions.update"
icon="trash"
size="medium"
type="tertiary"
@ -145,115 +241,11 @@
mode="execution"
loader-type="spinner"
:execution-id="executionId"
:execution-mode="executionMode"
:execution-mode="execution?.mode || ''"
/>
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { ElDropdown } from 'element-plus';
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
import { useMessage } from '@/composables/useMessage';
import WorkflowPreview from '@/components/WorkflowPreview.vue';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import type { ExecutionSummary } from 'n8n-workflow';
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { mapStores } from 'pinia';
type RetryDropdownRef = InstanceType<typeof ElDropdown> & { hide: () => void };
export default defineComponent({
name: 'WorkflowExecutionsPreview',
components: {
ElDropdown,
WorkflowPreview,
},
props: {
execution: {
type: Object as PropType<ExecutionSummary>,
required: true,
},
},
setup() {
const executionHelpers = useExecutionHelpers();
return {
executionHelpers,
...useMessage(),
...useExecutionDebugging(),
};
},
computed: {
...mapStores(useWorkflowsStore),
executionId(): string {
return this.$route.params.executionId as string;
},
executionUIDetails(): IExecutionUIData | null {
return this.execution ? this.executionHelpers.getUIDetails(this.execution) : null;
},
executionMode(): string {
return this.execution?.mode || '';
},
debugButtonData(): Record<string, string> {
return this.execution?.status === 'success'
? {
text: this.$locale.baseText('executionsList.debug.button.copyToEditor'),
type: 'secondary',
}
: {
text: this.$locale.baseText('executionsList.debug.button.debugInEditor'),
type: 'primary',
};
},
isRetriable(): boolean {
return !!this.execution && this.executionHelpers.isExecutionRetriable(this.execution);
},
executionDebugViewName() {
return VIEWS.EXECUTION_DEBUG;
},
executionPreviewViewName() {
return VIEWS.EXECUTION_PREVIEW;
},
},
methods: {
async onDeleteExecution(): Promise<void> {
const deleteConfirmed = await this.confirm(
this.$locale.baseText('executionDetails.confirmMessage.message'),
this.$locale.baseText('executionDetails.confirmMessage.headline'),
{
type: 'warning',
confirmButtonText: this.$locale.baseText(
'executionDetails.confirmMessage.confirmButtonText',
),
cancelButtonText: '',
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
this.$emit('deleteCurrentExecution');
},
handleRetryClick(command: string): void {
this.$emit('retryExecution', { execution: this.execution, command });
},
handleStopClick(): void {
this.$emit('stopExecution');
},
onRetryButtonBlur(event: FocusEvent): void {
// Hide dropdown when clicking outside of current document
const retryDropdownRef = this.$refs.retryDropdown as RetryDropdownRef | undefined;
if (retryDropdownRef && event.relatedTarget === null) {
retryDropdownRef.handleClose();
}
},
},
});
</script>
<style module lang="scss">
.previewContainer {
position: relative;
@ -321,7 +313,6 @@ export default defineComponent({
.debugLink {
margin-right: var(--spacing-xs);
padding: 0;
a > span {
display: block;

View file

@ -1,6 +1,170 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import type { ComponentPublicInstance } from 'vue';
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
import WorkflowExecutionsInfoAccordion from '@/components/executions/workflow/WorkflowExecutionsInfoAccordion.vue';
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
import { VIEWS } from '@/constants';
import type { ExecutionSummary } from 'n8n-workflow';
import { useExecutionsStore } from '@/stores/executions.store';
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
import { isComponentPublicInstance } from '@/utils/typeGuards';
import { getResourcePermissions } from '@/permissions';
type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean };
const props = defineProps<{
workflow?: IWorkflowDb;
executions: ExecutionSummary[];
loading: boolean;
loadingMore: boolean;
temporaryExecution?: ExecutionSummary;
}>();
const emit = defineEmits<{
retryExecution: [payload: { execution: ExecutionSummary; command: string }];
loadMore: [amount: number];
filterUpdated: [filter: ExecutionFilterType];
'update:autoRefresh': [boolean];
}>();
const route = useRoute();
const router = useRouter();
const executionsStore = useExecutionsStore();
const mountedItems = ref<string[]>([]);
const autoScrollDeps = ref<AutoScrollDeps>({
activeExecutionSet: false,
cardsMounted: false,
scroll: true,
});
const currentWorkflowExecutionsCardRefs = ref<Record<string, ComponentPublicInstance>>({});
const sidebarContainerRef = ref<HTMLElement | null>(null);
const executionListRef = ref<HTMLElement | null>(null);
const workflowPermissions = computed(() => getResourcePermissions(props.workflow?.scopes).workflow);
watch(
() => route,
(to: RouteLocationNormalizedLoaded, from: RouteLocationNormalizedLoaded) => {
if (from.name === VIEWS.EXECUTION_PREVIEW && to.name === VIEWS.EXECUTION_HOME) {
// Skip parent route when navigating through executions with back button
router.go(-1);
}
},
);
watch(
() => executionsStore.activeExecution,
(newValue: ExecutionSummary | null, oldValue: ExecutionSummary | null) => {
if (newValue && newValue.id !== oldValue?.id) {
autoScrollDeps.value.activeExecutionSet = true;
}
},
);
watch(
autoScrollDeps,
(updatedDeps: AutoScrollDeps) => {
if (Object.values(updatedDeps).every(Boolean)) {
scrollToActiveCard();
}
},
{ deep: true },
);
function addCurrentWorkflowExecutionsCardRef(
comp: Element | ComponentPublicInstance | null,
id?: string,
) {
if (comp && isComponentPublicInstance(comp) && id) {
currentWorkflowExecutionsCardRefs.value[id] = comp;
}
}
function onItemMounted(id: string): void {
mountedItems.value.push(id);
if (mountedItems.value.length === props.executions.length) {
autoScrollDeps.value.cardsMounted = true;
checkListSize();
}
if (executionsStore.activeExecution?.id === id) {
autoScrollDeps.value.activeExecutionSet = true;
}
}
function loadMore(limit = 20): void {
if (!props.loading) {
if (executionListRef.value) {
const diff =
executionListRef.value.offsetHeight -
(executionListRef.value.scrollHeight - executionListRef.value.scrollTop);
if (diff > -10 && diff < 10) {
emit('loadMore', limit);
}
}
}
}
function onRetryExecution(payload: { execution: ExecutionSummary; command: string }) {
emit('retryExecution', payload);
}
function onFilterChanged(filter: ExecutionFilterType) {
autoScrollDeps.value.activeExecutionSet = false;
autoScrollDeps.value.scroll = true;
mountedItems.value = [];
emit('filterUpdated', filter);
}
function onAutoRefreshChange(enabled: boolean) {
emit('update:autoRefresh', enabled);
}
function checkListSize(): void {
// Find out how many execution card can fit into list
// and load more if needed
const cards = Object.values(currentWorkflowExecutionsCardRefs.value);
if (sidebarContainerRef.value && cards.length) {
const cardElement = cards[0].$el as HTMLElement;
const listCapacity = Math.ceil(
sidebarContainerRef.value.clientHeight / cardElement.clientHeight,
);
if (listCapacity > props.executions.length) {
emit('loadMore', listCapacity - props.executions.length);
}
}
}
function scrollToActiveCard(): void {
if (
executionListRef.value &&
executionsStore.activeExecution &&
currentWorkflowExecutionsCardRefs.value[executionsStore.activeExecution.id]
) {
const cardElement =
currentWorkflowExecutionsCardRefs.value[executionsStore.activeExecution.id].$el;
const cardRect = cardElement.getBoundingClientRect();
const LIST_HEADER_OFFSET = 200;
if (cardRect.top > executionListRef.value.offsetHeight) {
autoScrollDeps.value.scroll = false;
executionListRef.value.scrollTo({
top: cardRect.top - LIST_HEADER_OFFSET,
behavior: 'smooth',
});
}
}
}
</script>
<template>
<div
ref="container"
ref="sidebarContainerRef"
:class="['executions-sidebar', $style.container]"
data-test-id="executions-sidebar"
>
@ -13,14 +177,14 @@
<el-checkbox
v-model="executionsStore.autoRefresh"
data-test-id="auto-refresh-checkbox"
@update:model-value="$emit('update:autoRefresh', $event)"
@update:model-value="onAutoRefreshChange"
>
{{ $locale.baseText('executionsList.autoRefresh') }}
</el-checkbox>
<ExecutionsFilter popover-placement="left-start" @filter-changed="onFilterChanged" />
</div>
<div
ref="executionList"
ref="executionListRef"
:class="$style.executionList"
data-test-id="current-executions-list"
@scroll="loadMore(20)"
@ -39,18 +203,20 @@
</div>
<WorkflowExecutionsCard
v-else-if="temporaryExecution"
:ref="`execution-${temporaryExecution.id}`"
:ref="(el) => addCurrentWorkflowExecutionsCardRef(el, temporaryExecution?.id)"
:execution="temporaryExecution"
:data-test-id="`execution-details-${temporaryExecution.id}`"
:show-gap="true"
:workflow-permissions="workflowPermissions"
@retry-execution="onRetryExecution"
/>
<TransitionGroup name="executions-list">
<WorkflowExecutionsCard
v-for="execution in executions"
:key="execution.id"
:ref="`execution-${execution.id}`"
:ref="(el) => addCurrentWorkflowExecutionsCardRef(el, execution.id)"
:execution="execution"
:workflow-permissions="workflowPermissions"
:data-test-id="`execution-details-${execution.id}`"
@retry-execution="onRetryExecution"
@mounted="onItemMounted"
@ -66,178 +232,6 @@
</div>
</template>
<script lang="ts">
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
import WorkflowExecutionsInfoAccordion from '@/components/executions/workflow/WorkflowExecutionsInfoAccordion.vue';
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
import { VIEWS } from '@/constants';
import type { ExecutionSummary } from 'n8n-workflow';
import type { RouteRecord } from 'vue-router';
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import { useExecutionsStore } from '@/stores/executions.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ExecutionFilterType } from '@/Interface';
type WorkflowExecutionsCardRef = InstanceType<typeof WorkflowExecutionsCard>;
type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean };
export default defineComponent({
name: 'WorkflowExecutionsSidebar',
components: {
WorkflowExecutionsCard,
WorkflowExecutionsInfoAccordion,
ExecutionsFilter,
},
props: {
executions: {
type: Array as PropType<ExecutionSummary[]>,
required: true,
},
loading: {
type: Boolean,
default: true,
},
loadingMore: {
type: Boolean,
default: false,
},
temporaryExecution: {
type: Object as PropType<ExecutionSummary>,
default: null,
},
},
emits: {
retryExecution: null,
loadMore: null,
refresh: null,
filterUpdated: null,
reloadExecutions: null,
'update:autoRefresh': null,
},
data() {
return {
filter: {} as ExecutionFilterType,
mountedItems: [] as string[],
autoScrollDeps: {
activeExecutionSet: false,
cardsMounted: false,
scroll: true,
} as AutoScrollDeps,
};
},
computed: {
...mapStores(useExecutionsStore, useWorkflowsStore),
},
watch: {
$route(to: RouteRecord, from: RouteRecord) {
if (from.name === VIEWS.EXECUTION_PREVIEW && to.name === VIEWS.EXECUTION_HOME) {
// Skip parent route when navigating through executions with back button
this.$router.go(-1);
}
},
'executionsStore.activeExecution'(
newValue: ExecutionSummary | null,
oldValue: ExecutionSummary | null,
) {
if (newValue && newValue.id !== oldValue?.id) {
this.autoScrollDeps.activeExecutionSet = true;
}
},
autoScrollDeps: {
handler(updatedDeps: AutoScrollDeps) {
if (Object.values(updatedDeps).every(Boolean)) {
this.scrollToActiveCard();
}
},
deep: true,
},
},
methods: {
onItemMounted(id: string): void {
this.mountedItems.push(id);
if (this.mountedItems.length === this.executions.length) {
this.autoScrollDeps.cardsMounted = true;
this.checkListSize();
}
if (this.executionsStore.activeExecution?.id === id) {
this.autoScrollDeps.activeExecutionSet = true;
}
},
loadMore(limit = 20): void {
if (!this.loading) {
const executionsListRef = this.$refs.executionList as HTMLElement | undefined;
if (executionsListRef) {
const diff =
executionsListRef.offsetHeight -
(executionsListRef.scrollHeight - executionsListRef.scrollTop);
if (diff > -10 && diff < 10) {
this.$emit('loadMore', limit);
}
}
}
},
onRetryExecution(payload: object) {
this.$emit('retryExecution', payload);
},
onRefresh(): void {
this.$emit('refresh');
},
onFilterChanged(filter: ExecutionFilterType) {
this.autoScrollDeps.activeExecutionSet = false;
this.autoScrollDeps.scroll = true;
this.mountedItems = [];
this.$emit('filterUpdated', filter);
},
reloadExecutions(): void {
this.$emit('reloadExecutions');
},
checkListSize(): void {
const sidebarContainerRef = this.$refs.container as HTMLElement | undefined;
const currentWorkflowExecutionsCardRefs = this.$refs[
`execution-${this.mountedItems[this.mountedItems.length - 1]}`
] as WorkflowExecutionsCardRef[] | undefined;
// Find out how many execution card can fit into list
// and load more if needed
if (sidebarContainerRef && currentWorkflowExecutionsCardRefs?.length) {
const cardElement = currentWorkflowExecutionsCardRefs[0].$el as HTMLElement;
const listCapacity = Math.ceil(sidebarContainerRef.clientHeight / cardElement.clientHeight);
if (listCapacity > this.executions.length) {
this.$emit('loadMore', listCapacity - this.executions.length);
}
}
},
scrollToActiveCard(): void {
const executionsListRef = this.$refs.executionList as HTMLElement | undefined;
const currentWorkflowExecutionsCardRefs = this.$refs[
`execution-${this.executionsStore.activeExecution?.id}`
] as WorkflowExecutionsCardRef[] | undefined;
if (
executionsListRef &&
currentWorkflowExecutionsCardRefs?.length &&
this.executionsStore.activeExecution
) {
const cardElement = currentWorkflowExecutionsCardRefs[0].$el as HTMLElement;
const cardRect = cardElement.getBoundingClientRect();
const LIST_HEADER_OFFSET = 200;
if (cardRect.top > executionsListRef.offsetHeight) {
this.autoScrollDeps.scroll = false;
executionsListRef.scrollTo({
top: cardRect.top - LIST_HEADER_OFFSET,
behavior: 'smooth',
});
}
}
},
},
});
</script>
<style module lang="scss">
.container {
flex: 310px 0 0;

View file

@ -23,8 +23,13 @@
:description="i18n.baseText(`${resourceKey}.empty.description` as BaseTextKey)"
:button-text="i18n.baseText(`${resourceKey}.empty.button` as BaseTextKey)"
button-type="secondary"
:button-disabled="disabled"
@click:button="onAddButtonClick"
/>
>
<template #disabledButtonTooltip>
{{ i18n.baseText(`${resourceKey}.empty.button.disabled.tooltip` as BaseTextKey) }}
</template>
</n8n-action-box>
</slot>
</div>
<PageViewLayoutList v-else :overflow="type !== 'list'">

View file

@ -38,6 +38,7 @@ describe('useContextMenu', () => {
workflowsStore = useWorkflowsStore();
workflowsStore.workflow.nodes = nodes;
workflowsStore.workflow.scopes = ['workflow:update'];
vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockReturnValue({
nodes,
getNode: (_: string) => {
@ -127,6 +128,7 @@ describe('useContextMenu', () => {
describe('Read-only mode', () => {
it('should return the correct actions when right clicking a sticky', () => {
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
workflowsStore.workflow.scopes = ['workflow:read'];
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);

View file

@ -10,6 +10,7 @@ import { getMousePosition } from '../utils/nodeViewUtils';
import { useI18n } from './useI18n';
import { usePinnedData } from './usePinnedData';
import { isPresent } from '../utils/typesUtils';
import { getResourcePermissions } from '@/permissions';
export type ContextMenuTarget =
| { source: 'canvas'; nodeIds: string[] }
@ -46,8 +47,15 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
const i18n = useI18n();
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.workflow.scopes).workflow,
);
const isReadOnly = computed(
() => sourceControlStore.preferences.branchReadOnly || uiStore.isReadOnlyView,
() =>
sourceControlStore.preferences.branchReadOnly ||
uiStore.isReadOnlyView ||
!workflowPermissions.value.update,
);
const targetNodeIds = computed(() => {

View file

@ -1,120 +1,118 @@
import {
getVariablesPermissions,
getProjectPermissions,
getCredentialPermissions,
getWorkflowPermissions,
} from '@/permissions';
import type { ICredentialsResponse, IUser, IWorkflowDb } from '@/Interface';
import type { Project } from '@/types/projects.types';
import type { PermissionsRecord } from '@/permissions';
import { getResourcePermissions } from '@/permissions';
import type { Scope } from '@n8n/permissions';
describe('permissions', () => {
it('getVariablesPermissions', () => {
expect(getVariablesPermissions(null)).toEqual({
create: false,
read: false,
update: false,
delete: false,
list: false,
});
expect(
getVariablesPermissions({
globalScopes: [
'variable:create',
'variable:read',
'variable:update',
'variable:delete',
'variable:list',
],
} as IUser),
).toEqual({
create: true,
read: true,
update: true,
delete: true,
list: true,
});
expect(
getVariablesPermissions({
globalScopes: ['variable:read', 'variable:list'],
} as IUser),
).toEqual({
create: false,
read: true,
update: false,
delete: false,
list: true,
it('getResourcePermissions for empty scopes', () => {
expect(getResourcePermissions()).toEqual({
auditLogs: {},
banner: {},
communityPackage: {},
credential: {},
externalSecretsProvider: {},
externalSecret: {},
eventBusDestination: {},
ldap: {},
license: {},
logStreaming: {},
orchestration: {},
project: {},
saml: {},
securityAudit: {},
sourceControl: {},
tag: {},
user: {},
variable: {},
workersView: {},
workflow: {},
});
});
it('getResourcePermissions', () => {
const scopes: Scope[] = [
'credential:create',
'credential:delete',
'credential:list',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
'eventBusDestination:list',
'eventBusDestination:test',
'project:list',
'project:read',
'tag:create',
'tag:list',
'tag:read',
'tag:update',
'user:list',
'variable:list',
'variable:read',
'workflow:create',
'workflow:delete',
'workflow:execute',
'workflow:list',
'workflow:move',
'workflow:read',
'workflow:share',
'workflow:update',
];
it('getProjectPermissions', () => {
expect(
getProjectPermissions({
scopes: [
'project:create',
'project:read',
'project:update',
'project:delete',
'project:list',
],
} as Project),
).toEqual({
create: true,
read: true,
update: true,
delete: true,
list: true,
});
});
const permissionRecord: PermissionsRecord = {
auditLogs: {},
banner: {},
communityPackage: {},
credential: {
create: true,
delete: true,
list: true,
move: true,
read: true,
share: true,
update: true,
},
eventBusDestination: {
list: true,
test: true,
},
externalSecret: {},
externalSecretsProvider: {},
ldap: {},
license: {},
logStreaming: {},
orchestration: {},
project: {
list: true,
read: true,
},
saml: {},
securityAudit: {},
sourceControl: {},
tag: {
create: true,
list: true,
read: true,
update: true,
},
user: {
list: true,
},
variable: {
list: true,
read: true,
},
workersView: {},
workflow: {
create: true,
delete: true,
execute: true,
list: true,
move: true,
read: true,
share: true,
update: true,
},
};
it('getCredentialPermissions', () => {
expect(
getCredentialPermissions({
scopes: [
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:share',
'credential:move',
],
} as ICredentialsResponse),
).toEqual({
create: true,
read: true,
update: true,
delete: true,
list: true,
share: true,
move: true,
});
});
it('getWorkflowPermissions', () => {
expect(
getWorkflowPermissions({
scopes: [
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:share',
'workflow:execute',
'workflow:move',
],
} as IWorkflowDb),
).toEqual({
create: true,
read: true,
update: true,
delete: true,
list: true,
share: true,
execute: true,
move: true,
});
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);
});
});

View file

@ -1,66 +1,32 @@
import type { IUser, ICredentialsResponse, IWorkflowDb } from '@/Interface';
import type {
CredentialScope,
ProjectScope,
Scope,
WorkflowScope,
VariableScope,
} from '@n8n/permissions';
import type { Project } from '@/types/projects.types';
import type { Scope } from '@n8n/permissions';
import { RESOURCES } from '@n8n/permissions';
type ExtractAfterColon<T> = T extends `${infer _Prefix}:${infer Suffix}` ? Suffix : never;
export type PermissionsMap<T> = {
[K in ExtractAfterColon<T>]: boolean;
type ExtractScopePrefixSuffix<T> = T extends `${infer Prefix}:${infer Suffix}`
? [Prefix, Suffix]
: never;
type ActionBooleans<T extends readonly string[]> = {
[K in T[number]]?: boolean;
};
export type PermissionsRecord = {
[K in keyof typeof RESOURCES]: ActionBooleans<(typeof RESOURCES)[K]>;
};
const mapScopesToPermissions = <T extends Scope>(scopes: T[], scopeSet: Set<T>) =>
scopes.reduce(
(permissions, scope) => ({
export const getResourcePermissions = (resourceScopes: Scope[] = []): PermissionsRecord =>
Object.keys(RESOURCES).reduce(
(permissions, key) => ({
...permissions,
[scope.split(':')[1]]: scopeSet.has(scope),
[key]: resourceScopes.reduce((resourcePermissions, scope) => {
const [prefix, suffix] = scope.split(':') as ExtractScopePrefixSuffix<Scope>;
if (prefix === key) {
return {
...resourcePermissions,
[suffix]: true,
};
}
return resourcePermissions;
}, {}),
}),
{} as PermissionsMap<T>,
);
export const getCredentialPermissions = (
credential: ICredentialsResponse,
): PermissionsMap<CredentialScope> =>
mapScopesToPermissions(
[
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:share',
'credential:move',
],
new Set(credential?.scopes ?? []),
);
export const getWorkflowPermissions = (workflow: IWorkflowDb): PermissionsMap<WorkflowScope> =>
mapScopesToPermissions(
[
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:share',
'workflow:execute',
'workflow:move',
],
new Set(workflow?.scopes ?? []),
);
export const getProjectPermissions = (project: Project | null): PermissionsMap<ProjectScope> =>
mapScopesToPermissions(
['project:create', 'project:read', 'project:update', 'project:delete', 'project:list'],
new Set(project?.scopes ?? []),
);
export const getVariablesPermissions = (user: IUser | null): PermissionsMap<VariableScope> =>
mapScopesToPermissions(
['variable:create', 'variable:read', 'variable:update', 'variable:delete', 'variable:list'],
new Set(user?.globalScopes ?? []),
{} as PermissionsRecord,
);

View file

@ -574,6 +574,7 @@
"credentials.empty.heading.userNotSetup": "Set up a credential",
"credentials.empty.description": "Credentials let workflows interact with your apps and services",
"credentials.empty.button": "Add first credential",
"credentials.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create credentials",
"credentials.item.open": "Open",
"credentials.item.delete": "Delete",
"credentials.item.move": "Move",
@ -2189,8 +2190,10 @@
"workflows.empty.heading.userNotSetup": "👋 Welcome!",
"workflows.empty.description": "Create your first workflow",
"workflows.empty.description.readOnlyEnv": "No workflows here yet",
"workflows.empty.description.noPermission": "There are currently no workflows to view",
"workflows.empty.startFromScratch": "Start from scratch",
"workflows.empty.browseTemplates": "Browse {category} templates",
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
"workflows.shareModal.title": "Share '{name}'",
"workflows.shareModal.title.static": "Shared with {projectName}",
"workflows.shareModal.select.placeholder": "Add users...",
@ -2241,6 +2244,7 @@
"variables.empty.heading.userNotSetup": "Set up a variable",
"variables.empty.description": "Variables can be used to store data that can be referenced easily across multiple workflows.",
"variables.empty.button": "Add first variable",
"variables.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create variables",
"variables.empty.notAllowedToCreate.heading": "{name}, start using variables",
"variables.empty.notAllowedToCreate.description": "Ask your n8n instance owner to create the variables you need. Once configured, you can utilize them in your workflows using the syntax $vars.MY_VAR.",
"variables.noResults": "No variables found",
@ -2444,6 +2448,7 @@
"projects.settings.button.deleteProject": "Delete project",
"projects.settings.role.admin": "Admin",
"projects.settings.role.editor": "Editor",
"projects.settings.role.viewer": "Viewer",
"projects.settings.delete.title": "Delete {projectName}",
"projects.settings.delete.message": "What should we do with the project data?",
"projects.settings.delete.question.transfer.label": "Transfer its workflows and credentials to another project or user",

View file

@ -74,11 +74,13 @@ export const useCanvasStore = defineStore('canvas', () => {
() => lastSelectedConnection.value,
);
watch(readOnlyEnv, (readOnly) => {
const setReadOnly = (readOnly: boolean) => {
if (jsPlumbInstanceRef.value) {
jsPlumbInstanceRef.value.elementsDraggable = !readOnly;
jsPlumbInstanceRef.value.setDragConstrainFunction(((pos: PointXY) =>
readOnly ? null : pos) as ConstrainFunction);
}
});
};
const setLastSelectedConnection = (connection: Connection | undefined) => {
lastSelectedConnection.value = connection;
@ -255,7 +257,7 @@ export const useCanvasStore = defineStore('canvas', () => {
if (!nodeName) return;
const nodeData = workflowStore.getNodeByName(nodeName);
isDragging.value = false;
if (uiStore.isActionActive['dragActive'] && nodeData) {
if (uiStore.isActionActive.dragActive && nodeData) {
const moveNodes = uiStore.getSelectedNodes.slice();
const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name);
if (!selectedNodeNames.includes(nodeData.name)) {
@ -300,7 +302,7 @@ export const useCanvasStore = defineStore('canvas', () => {
if (moveNodes.length > 1) {
historyStore.stopRecordingUndo();
}
if (uiStore.isActionActive['dragActive']) {
if (uiStore.isActionActive.dragActive) {
uiStore.removeActiveAction('dragActive');
}
}
@ -319,6 +321,9 @@ export const useCanvasStore = defineStore('canvas', () => {
}
const jsPlumbInstance = computed(() => jsPlumbInstanceRef.value as BrowserJsPlumbInstance);
watch(readOnlyEnv, setReadOnly);
return {
isDemo,
nodeViewScale,
@ -328,6 +333,7 @@ export const useCanvasStore = defineStore('canvas', () => {
isLoading: loadingService.isLoading,
aiNodes,
lastSelectedConnection: lastSelectedConnectionComputed,
setReadOnly,
setLastSelectedConnection,
startLoading: loadingService.startLoading,
setLoadingText: loadingService.setLoadingText,

View file

@ -101,6 +101,7 @@ describe('roles store', () => {
});
await rolesStore.fetchRoles();
expect(rolesStore.processedProjectRoles.map(({ role }) => role)).toEqual([
'project:viewer',
'project:editor',
'project:admin',
]);

View file

@ -13,7 +13,11 @@ export const useRolesStore = defineStore('roles', () => {
credential: [],
workflow: [],
});
const projectRoleOrder = ref<ProjectRole[]>(['project:editor', 'project:admin']);
const projectRoleOrder = ref<ProjectRole[]>([
'project:viewer',
'project:editor',
'project:admin',
]);
const projectRoleOrderMap = computed<Map<ProjectRole, number>>(
() => new Map(projectRoleOrder.value.map((role, idx) => [role, idx])),
);

View file

@ -12,6 +12,7 @@ import type { Connection as VueFlowConnection } from '@vue-flow/core';
import type { RouteLocationRaw } from 'vue-router';
import type { CanvasConnectionMode } from '@/types';
import { canvasConnectionModes } from '@/types';
import type { ComponentPublicInstance } from 'vue';
/*
Type guards used in editor-ui project
@ -103,3 +104,7 @@ export function isRouteLocationRaw(value: unknown): value is RouteLocationRaw {
(typeof value === 'object' && value !== null && ('name' in value || 'path' in value))
);
}
export function isComponentPublicInstance(value: unknown): value is ComponentPublicInstance {
return value !== null && typeof value === 'object' && '$props' in value;
}

View file

@ -8,6 +8,7 @@
:additional-filters-handler="onFilter"
:type-props="{ itemSize: 77 }"
:loading="loading"
:disabled="readOnlyEnv || !projectPermissions.credential.create"
@click:add="addCredential"
@update:filters="filters = $event"
>
@ -79,6 +80,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useSettingsStore } from '@/stores/settings.store';
import { getResourcePermissions } from '@/permissions';
export default defineComponent({
name: 'CredentialsView',
@ -131,6 +133,14 @@ export default defineComponent({
? this.$locale.baseText('credentials.project.add')
: this.$locale.baseText('credentials.add');
},
readOnlyEnv(): boolean {
return this.sourceControlStore.preferences.branchReadOnly;
},
projectPermissions() {
return getResourcePermissions(
this.projectsStore.currentProject?.scopes ?? this.projectsStore.personalProject?.scopes,
);
},
},
watch: {
'$route.params.projectId'() {

View file

@ -47,7 +47,11 @@
v-for="nodeData in nodesToRender"
:key="`${nodeData.id}_node`"
:name="nodeData.name"
:is-read-only="isReadOnlyRoute || readOnlyEnv"
:is-read-only="
isReadOnlyRoute ||
readOnlyEnv ||
!(workflowPermissions.update ?? projectPermissions.workflow.update)
"
:instance="instance"
:is-active="!!activeNode && activeNode.name === nodeData.name"
:hide-actions="pullConnActive"
@ -75,7 +79,11 @@
:key="`${stickyData.id}_sticky`"
:name="stickyData.name"
:workflow="currentWorkflowObject"
:is-read-only="isReadOnlyRoute || readOnlyEnv"
:is-read-only="
isReadOnlyRoute ||
readOnlyEnv ||
!(workflowPermissions.update ?? projectPermissions.workflow.update)
"
:instance="instance"
:is-active="!!activeNode && activeNode.name === stickyData.name"
:node-view-scale="nodeViewScale"
@ -90,7 +98,11 @@
</div>
<NodeDetailsView
:workflow-object="currentWorkflowObject"
:read-only="isReadOnlyRoute || readOnlyEnv"
:read-only="
isReadOnlyRoute ||
readOnlyEnv ||
!(workflowPermissions.update ?? projectPermissions.workflow.update)
"
:renaming="renamingActive"
:is-production-execution-preview="isProductionExecutionPreview"
@redraw-node="redrawNode"
@ -107,7 +119,11 @@
</Suspense>
<Suspense>
<LazyNodeCreation
v-if="!isReadOnlyRoute && !readOnlyEnv"
v-if="
!isReadOnlyRoute &&
!readOnlyEnv &&
(workflowPermissions.update ?? projectPermissions.workflow.update)
"
:create-node-active="createNodeActive"
:node-view-scale="nodeViewScale"
@toggle-node-creator="onToggleNodeCreator"
@ -120,7 +136,14 @@
<Suspense>
<ContextMenu @action="onContextMenuAction" />
</Suspense>
<div v-if="!isReadOnlyRoute && !readOnlyEnv" class="workflow-execute-wrapper">
<div
v-if="
!isReadOnlyRoute &&
!readOnlyEnv &&
(workflowPermissions.update ?? projectPermissions.workflow.update)
"
class="workflow-execute-wrapper"
>
<span
v-if="!isManualChatOnly"
@mouseenter="showTriggerMissingToltip(true)"
@ -182,13 +205,7 @@
/>
<n8n-icon-button
v-if="
!isReadOnlyRoute &&
!readOnlyEnv &&
workflowExecution &&
!workflowRunning &&
!allTriggersDisabled
"
v-if="workflowExecution && !workflowRunning && !allTriggersDisabled"
:title="$locale.baseText('nodeView.deletesTheCurrentExecutionData')"
icon="trash"
size="large"
@ -383,6 +400,7 @@ import type { ProjectSharingData } from '@/types/projects.types';
import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuards';
import { usePostHog } from '@/stores/posthog.store';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { getResourcePermissions } from '@/permissions';
interface AddNodeOptions {
position?: XYPosition;
@ -538,7 +556,13 @@ export default defineComponent({
return this.$route.name === VIEWS.DEMO;
},
showCanvasAddButton(): boolean {
return !this.isLoading && !this.containsTrigger && !this.isDemo && !this.readOnlyEnv;
return (
!this.isLoading &&
!this.containsTrigger &&
!this.isDemo &&
!this.readOnlyEnv &&
!!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)
);
},
lastSelectedNode(): INodeUi | null {
return this.uiStore.getLastSelectedNode;
@ -579,7 +603,8 @@ export default defineComponent({
return NodeViewUtils.getBackgroundStyles(
this.nodeViewScale,
this.uiStore.nodeViewOffsetPosition,
this.isExecutionPreview,
this.isExecutionPreview ||
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update),
);
},
workflowClasses() {
@ -687,11 +712,22 @@ export default defineComponent({
isProductionExecutionPreview(): boolean {
return this.nodeHelpers.isProductionExecutionPreview.value;
},
workflowPermissions() {
return getResourcePermissions(
this.workflowsStore.getWorkflowById(this.currentWorkflow)?.scopes,
).workflow;
},
projectPermissions() {
const project = this.$route.query?.projectId
? this.projectsStore.myProjects.find((p) => p.id === this.$route.query.projectId)
: this.projectsStore.currentProject ?? this.projectsStore.personalProject;
return getResourcePermissions(project?.scopes);
},
},
watch: {
// Listen to route changes and load the workflow accordingly
async $route(to: RouteLocation, from: RouteLocation) {
this.readOnlyEnvRouteCheck();
await this.readOnlyEnvRouteCheck();
const currentTab = getNodeViewTab(to);
const nodeViewNotInitialized = !this.uiStore.nodeViewInitialized;
@ -858,7 +894,7 @@ export default defineComponent({
},
});
this.readOnlyEnvRouteCheck();
await this.readOnlyEnvRouteCheck();
this.canvasStore.isDemo = this.isDemo;
},
activated() {
@ -1005,7 +1041,8 @@ export default defineComponent({
return false;
}
return true;
return !!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update);
},
showTriggerMissingToltip(isVisible: boolean) {
this.showTriggerMissingTooltip = isVisible;
@ -1405,14 +1442,17 @@ export default defineComponent({
const shiftModifier = e.shiftKey && !e.altKey && !this.deviceSupport.isCtrlKeyPressed(e);
const ctrlAltModifier = this.deviceSupport.isCtrlKeyPressed(e) && e.altKey && !e.shiftKey;
const noModifierKeys = !this.deviceSupport.isCtrlKeyPressed(e) && !e.shiftKey && !e.altKey;
const readOnly = this.isReadOnlyRoute || this.readOnlyEnv;
const readOnly =
this.isReadOnlyRoute ||
this.readOnlyEnv ||
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update);
if (e.key === 's' && ctrlModifier && !readOnly) {
e.stopPropagation();
e.preventDefault();
const workflowIsSaved = !this.uiStore.stateIsDirty;
if (this.isReadOnlyRoute || this.readOnlyEnv || workflowIsSaved) {
if (workflowIsSaved) {
return;
}
@ -1538,7 +1578,9 @@ export default defineComponent({
if (lastSelectedNode !== null) {
if (
lastSelectedNode.type === STICKY_NODE_TYPE &&
(this.isReadOnlyRoute || this.readOnlyEnv)
(this.isReadOnlyRoute ||
this.readOnlyEnv ||
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update))
) {
return;
}
@ -1825,7 +1867,10 @@ export default defineComponent({
},
cutNodes(nodes: INode[]) {
const deleteCopiedNodes = !this.isReadOnlyRoute && !this.readOnlyEnv;
const deleteCopiedNodes =
!this.isReadOnlyRoute &&
!this.readOnlyEnv &&
(this.workflowPermissions.update ?? this.projectPermissions.workflow.update);
this.copyNodes(nodes, deleteCopiedNodes);
if (deleteCopiedNodes) {
this.deleteNodes(nodes);
@ -1959,7 +2004,11 @@ export default defineComponent({
* This method gets called when data got pasted into the window
*/
async onClipboardPasteEvent(plainTextData: string): Promise<void> {
if (this.readOnlyEnv) {
if (
this.readOnlyEnv ||
this.isReadOnlyRoute ||
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)
) {
return;
}
@ -2704,7 +2753,10 @@ export default defineComponent({
this.instance?.connect({
uuids: [targetEndpoint, viableConnection?.uuid || ''],
detachable: !this.isReadOnlyRoute && !this.readOnlyEnv,
detachable:
!this.isReadOnlyRoute &&
!this.readOnlyEnv &&
(this.workflowPermissions.update ?? this.projectPermissions.workflow.update),
});
this.historyStore.stopRecordingUndo();
return;
@ -3000,7 +3052,11 @@ export default defineComponent({
this.dropPrevented = true;
this.workflowsStore.addConnection({ connection: connectionData });
if (!this.isReadOnlyRoute && !this.readOnlyEnv) {
if (
!this.isReadOnlyRoute &&
!this.readOnlyEnv &&
(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)
) {
NodeViewUtils.hideOutputNameLabel(info.sourceEndpoint);
NodeViewUtils.addConnectionActionsOverlay(
info.connection,
@ -3087,6 +3143,7 @@ export default defineComponent({
if (
// eslint-disable-next-line no-constant-binary-expression
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update) ??
this.isReadOnlyRoute ??
this.readOnlyEnv ??
this.enterTimer ??
@ -3125,6 +3182,7 @@ export default defineComponent({
if (
// eslint-disable-next-line no-constant-binary-expression
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update) ??
this.isReadOnlyRoute ??
this.readOnlyEnv ??
!connection ??
@ -3537,7 +3595,12 @@ export default defineComponent({
const templateId = this.$route.params.id;
await this.openWorkflowTemplate(templateId.toString());
} else {
if (this.uiStore.stateIsDirty && !this.readOnlyEnv) {
if (
this.uiStore.stateIsDirty &&
!this.readOnlyEnv &&
!this.isReadOnlyRoute &&
(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)
) {
const confirmModal = await this.confirm(
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
{
@ -3604,6 +3667,9 @@ export default defineComponent({
}
this.historyStore.reset();
if (!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)) {
this.canvasStore.setReadOnly(true);
}
this.uiStore.nodeViewInitialized = true;
document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp);
@ -4523,17 +4589,16 @@ export default defineComponent({
this.canvasStore.stopLoading();
}
},
readOnlyEnvRouteCheck() {
async readOnlyEnvRouteCheck() {
if (
this.readOnlyEnv &&
(this.readOnlyEnv || !this.projectPermissions.workflow.create) &&
(this.$route.name === VIEWS.NEW_WORKFLOW || this.$route.name === VIEWS.TEMPLATE_IMPORT)
) {
void this.$nextTick(async () => {
this.resetWorkspace();
this.uiStore.stateIsDirty = false;
await this.$nextTick();
this.resetWorkspace();
this.uiStore.stateIsDirty = false;
await this.$router.replace({ name: VIEWS.HOMEPAGE });
});
await this.$router.replace({ name: VIEWS.HOMEPAGE });
}
},
async checkAndInitDebugMode() {
@ -4583,7 +4648,10 @@ export default defineComponent({
case 'add_node':
this.onToggleNodeCreator({
source: NODE_CREATOR_OPEN_SOURCES.CONTEXT_MENU,
createNodeActive: !this.isReadOnlyRoute && !this.readOnlyEnv,
createNodeActive:
!this.isReadOnlyRoute &&
!this.readOnlyEnv &&
!!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update),
});
break;
case 'add_sticky':

View file

@ -43,6 +43,7 @@ const formData = ref<Pick<Project, 'name' | 'relations'>>({
relations: [],
});
const projectRoleTranslations = ref<{ [key: string]: string }>({
'project:viewer': locale.baseText('projects.settings.role.viewer'),
'project:editor': locale.baseText('projects.settings.role.editor'),
'project:admin': locale.baseText('projects.settings.role.admin'),
});

View file

@ -17,7 +17,7 @@ import VariablesRow from '@/components/VariablesRow.vue';
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
import type { DatatableColumn, EnvironmentVariable } from '@/Interface';
import { uid } from 'n8n-design-system/utils';
import { getVariablesPermissions } from '@/permissions';
import { getResourcePermissions } from '@/permissions';
import type { BaseTextKey } from '@/plugins/i18n';
const settingsStore = useSettingsStore();
@ -39,7 +39,10 @@ const TEMPORARY_VARIABLE_UID_BASE = '@tmpvar';
const allVariables = ref<EnvironmentVariable[]>([]);
const editMode = ref<Record<string, boolean>>({});
const loading = ref(false);
const permissions = getVariablesPermissions(usersStore.currentUser);
const permissions = computed(
() => getResourcePermissions(usersStore.currentUser?.globalScopes).variable,
);
const isFeatureEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables],
@ -49,7 +52,7 @@ const variablesToResources = computed((): IResource[] =>
allVariables.value.map((v) => ({ id: v.id, name: v.key, value: v.value })),
);
const canCreateVariables = computed(() => isFeatureEnabled.value && permissions.create);
const canCreateVariables = computed(() => isFeatureEnabled.value && permissions.value.create);
const datatableColumns = computed<DatatableColumn[]>(() => [
{

View file

@ -128,7 +128,7 @@ async function fetchWorkflow() {
toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title'));
}
}
workflow.value = workflowsStore.workflow;
workflow.value = workflowsStore.getWorkflowById(workflowId.value);
}
async function onAutoRefreshToggle(value: boolean) {
@ -172,7 +172,10 @@ async function onUpdateFilters(newFilters: ExecutionFilterType) {
await executionsStore.initialize(workflowId.value);
}
async function onExecutionStop(id: string) {
async function onExecutionStop(id?: string) {
if (!id) {
return;
}
try {
await executionsStore.stopCurrentExecution(id);
@ -190,7 +193,10 @@ async function onExecutionStop(id: string) {
}
}
async function onExecutionDelete(id: string) {
async function onExecutionDelete(id?: string) {
if (!id) {
return;
}
loading.value = true;
try {
const executionIndex = executions.value.findIndex((e: ExecutionSummary) => e.id === id);

View file

@ -19,6 +19,7 @@ import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { telemetry } from '@/plugins/telemetry';
import { useRootStore } from '@/stores/root.store';
import { getResourcePermissions } from '@/permissions';
type WorkflowHistoryActionRecord = {
[K in Uppercase<WorkflowHistoryActionTypes[number]>]: Lowercase<K>;
@ -65,10 +66,15 @@ const editorRoute = computed(() => ({
name: workflowId.value,
},
}));
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
);
const actions = computed<UserAction[]>(() =>
workflowHistoryActionTypes.map((value) => ({
label: i18n.baseText(`workflowHistory.item.actions.${value}`),
disabled: false,
disabled:
(value === 'clone' && !workflowPermissions.value.create) ||
(value === 'restore' && !workflowPermissions.value.update),
value,
})),
);

View file

@ -8,7 +8,7 @@
:type-props="{ itemSize: 80 }"
:shareable="isShareable"
:initialize="initialize"
:disabled="readOnlyEnv"
:disabled="readOnlyEnv || !projectPermissions.workflow.create"
:loading="loading"
@click:add="addWorkflow"
@update:filters="onFiltersUpdated"
@ -61,17 +61,12 @@
: $locale.baseText('workflows.empty.heading.userNotSetup')
}}
</n8n-heading>
<n8n-text size="large" color="text-base">
{{
$locale.baseText(
readOnlyEnv
? 'workflows.empty.description.readOnlyEnv'
: 'workflows.empty.description',
)
}}
</n8n-text>
<n8n-text size="large" color="text-base">{{ emptyListDescription }}</n8n-text>
</div>
<div v-if="!readOnlyEnv" :class="['text-center', 'mt-2xl', $style.actionsContainer]">
<div
v-if="!readOnlyEnv && projectPermissions.workflow.create"
:class="['text-center', 'mt-2xl', $style.actionsContainer]"
>
<a
v-if="isSalesUser"
:href="getTemplateRepositoryURL()"
@ -162,6 +157,7 @@ import { useTagsStore } from '@/stores/tags.store';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { useTemplatesStore } from '@/stores/templates.store';
import { getResourcePermissions } from '@/permissions';
interface Filters {
search: string;
@ -260,6 +256,20 @@ const WorkflowsView = defineComponent({
? this.$locale.baseText('workflows.project.add')
: this.$locale.baseText('workflows.add');
},
projectPermissions() {
return getResourcePermissions(
this.projectsStore.currentProject?.scopes ?? this.projectsStore.personalProject?.scopes,
);
},
emptyListDescription() {
if (this.readOnlyEnv) {
return this.$locale.baseText('workflows.empty.description.readOnlyEnv');
} else if (!this.projectPermissions.workflow.create) {
return this.$locale.baseText('workflows.empty.description.noPermission');
} else {
return this.$locale.baseText('workflows.empty.description');
}
},
},
watch: {
filters: {

View file

@ -39,6 +39,7 @@
"@types/xml2js": "catalog:"
},
"dependencies": {
"@n8n/permissions": "workspace:*",
"@n8n/tournament": "1.0.3",
"@n8n_io/riot-tmpl": "4.0.0",
"ast-types": "0.15.2",

View file

@ -10,6 +10,7 @@ import type { URLSearchParams } from 'url';
import type { RequestBodyMatcher } from 'nock';
import type { Client as SSHClient } from 'ssh2';
import type { Scope } from '@n8n/permissions';
import type { AuthenticationMethod } from './Authentication';
import type { CODE_EXECUTION_MODES, CODE_LANGUAGES, LOG_LEVELS } from './Constants';
import type { IDeferredPromise } from './DeferredPromise';
@ -2463,6 +2464,7 @@ export interface ExecutionSummary {
nodeExecutionStatus?: {
[key: string]: IExecutionSummaryNodeExecutionResult;
};
scopes?: Scope[];
}
export interface IExecutionSummaryNodeExecutionResult {

View file

@ -1709,6 +1709,9 @@ importers:
packages/workflow:
dependencies:
'@n8n/permissions':
specifier: workspace:*
version: link:../@n8n/permissions
'@n8n/tournament':
specifier: 1.0.3
version: 1.0.3