feat: Add workflow sharing functionality and permissions (#4370)

* feat(editor): extract credentials view into reusable layout components for workflows view

* feat(editor): add workflow card and start work on empty state

* feat: add hoverable card and finish workflows empty state

* fix: undo workflows response interface changes

* chore: fix linting issues.

* fix: remove enterprise sharing env schema

* fix(editor): fix workflows resource view when sharing is enabled

* fix: change owner tag design and order

* feat: add personalization survey on workflows page

* fix: update component snapshots

* feat: refactored workflow card to use workflow-activator properly

* fix: fix workflow activator and proptypes

* fix: hide owner tag for workflow card until sharing is available

* fix: fixed ownedBy and sharedWith appearing for workflows list

* feat: update tags component design

* refactor: change resource filter select to n8n-user-select

* fix: made telemetry messages reusable

* chore: remove unused import

* refactor: fix component name casing

* refactor: use Vue.set to make workflow property reactive

* feat: add support for clicking on tags for filtering

* chore: fix tags linting issues

* fix: fix resources list layout when title words are very long

* refactor: add active and inactive status text to workflow activator

* fix: fix credentials and workflows sorting when name contains leading whitespace

* fix: remove wrongfully added style tag

* feat: add translations and storybook examples for truncated tags

* fix: remove enterprise sharing env from schema

* refactor: fix workflows module and workflows field store naming conflict

* feat: add workflow share button and open dummy modal

* feat: add workflow sharing modal (in progress)

* feat: add message when sharing disabled

* feat: add sharing messages based on flags

* feat: add workflow sharing api integration and readonly state handling

* fix: change how foreign credentials are handled

* refactor: migrate newly added workflow sharing store methods to pinia

* fix: update foreign credentials handler and add executable prop to node-settings

* fix: fix credentials display issue caused by addCredentials override

* fix: fix various issues when sharing from empty state

* fix: update node duplication credentials

* fix: revert defautl values for sharing env

* feat: hide share button behind feature flag

* chore: add env variable for sharing feature (testing only)

* fix: change enterprise-edition component casing
This commit is contained in:
Alex Grozav 2022-11-15 14:25:04 +02:00 committed by GitHub
parent d1ffc58aa4
commit 898c25fd7e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 567 additions and 97 deletions

View file

@ -892,6 +892,7 @@ export const schema = {
sharing: { sharing: {
format: Boolean, format: Boolean,
default: false, default: false,
env: 'N8N_SHARING_ENABLED',
}, },
}, },
// This is a temporary flag (acting as feature toggle) // This is a temporary flag (acting as feature toggle)
@ -899,6 +900,7 @@ export const schema = {
workflowSharingEnabled: { workflowSharingEnabled: {
format: Boolean, format: Boolean,
default: false, default: false,
env: 'N8N_WORKFLOW_SHARING_ENABLED',
}, },
}, },

View file

@ -15,8 +15,13 @@
> >
{{ t('nds.auth.roles.owner') }} {{ t('nds.auth.roles.owner') }}
</n8n-badge> </n8n-badge>
<slot
v-if="!user.isOwner && !readonly"
name="actions"
:user="user"
/>
<n8n-action-toggle <n8n-action-toggle
v-if="!user.isOwner && !readonly && getActions(user).length > 0" v-if="!user.isOwner && !readonly && getActions(user).length > 0 && actions.length > 0"
placement="bottom" placement="bottom"
:actions="getActions(user)" :actions="getActions(user)"
theme="dark" theme="dark"
@ -35,6 +40,7 @@ import N8nUserInfo from '../N8nUserInfo';
import Locale from '../../mixins/locale'; import Locale from '../../mixins/locale';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { t } from '../../locale'; import { t } from '../../locale';
import {PropType} from "vue";
export interface IUserListAction { export interface IUserListAction {
label: string; label: string;
@ -71,6 +77,10 @@ export default mixins(Locale).extend({
type: String, type: String,
default: () => t('nds.usersList.reinviteUser'), default: () => t('nds.usersList.reinviteUser'),
}, },
actions: {
type: Array as PropType<string[]>,
default: () => ['delete', 'reinvite'],
},
}, },
computed: { computed: {
sortedUsers(): IUser[] { sortedUsers(): IUser[] {
@ -113,6 +123,7 @@ export default mixins(Locale).extend({
}, },
methods: { methods: {
getActions(user: IUser): IUserListAction[] { getActions(user: IUser): IUserListAction[] {
const actions = [];
const DELETE: IUserListAction = { const DELETE: IUserListAction = {
label: this.deleteLabel as string, label: this.deleteLabel as string,
value: 'delete', value: 'delete',
@ -127,16 +138,17 @@ export default mixins(Locale).extend({
return []; return [];
} }
if (user.firstName) { if (!user.firstName) {
return [ if (this.actions.includes('reinvite')) {
DELETE, actions.push(REINVITE);
]; }
} else {
return [
REINVITE,
DELETE,
];
} }
if (this.actions.includes('delete')) {
actions.push(DELETE);
}
return actions;
}, },
onUserAction(user: IUser, action: string): void { onUserAction(user: IUser, action: string): void {
if (action === 'delete' || action === 'reinvite') { if (action === 'delete' || action === 'reinvite') {

View file

@ -37,6 +37,7 @@ import {
NodeParameterValueType, NodeParameterValueType,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { FAKE_DOOR_FEATURES } from './constants'; import { FAKE_DOOR_FEATURES } from './constants';
import {ICredentialsDb} from "n8n";
export * from 'n8n-design-system/src/types'; export * from 'n8n-design-system/src/types';
@ -320,6 +321,7 @@ export interface IWorkflowDb {
sharedWith?: Array<Partial<IUser>>; sharedWith?: Array<Partial<IUser>>;
ownedBy?: Partial<IUser>; ownedBy?: Partial<IUser>;
hash: string; hash: string;
usedCredentials?: Array<Partial<ICredentialsDb>>;
} }
// Identical to cli.Interfaces.ts // Identical to cli.Interfaces.ts
@ -332,6 +334,14 @@ export interface IWorkflowShortResponse {
tags: ITag[]; tags: ITag[];
} }
export interface IWorkflowsShareResponse {
id: string;
createdAt: number | string;
updatedAt: number | string;
sharedWith?: Array<Partial<IUser>>;
ownedBy?: Partial<IUser>;
}
// Identical or almost identical to cli.Interfaces.ts // Identical or almost identical to cli.Interfaces.ts
@ -346,12 +356,17 @@ export interface IShareCredentialsPayload {
shareWithIds: string[]; shareWithIds: string[];
} }
export interface IShareWorkflowsPayload {
shareWithIds: string[];
}
export interface ICredentialsResponse extends ICredentialsEncrypted { export interface ICredentialsResponse extends ICredentialsEncrypted {
id: string; id: string;
createdAt: number | string; createdAt: number | string;
updatedAt: number | string; updatedAt: number | string;
sharedWith?: Array<Partial<IUser>>; sharedWith?: Array<Partial<IUser>>;
ownedBy?: Partial<IUser>; ownedBy?: Partial<IUser>;
currentUserHasAccess?: boolean;
} }
export interface ICredentialsBase { export interface ICredentialsBase {
@ -987,7 +1002,6 @@ export interface ICredentialMap {
export interface ICredentialsState { export interface ICredentialsState {
credentialTypes: ICredentialTypeMap; credentialTypes: ICredentialTypeMap;
credentials: ICredentialMap; credentials: ICredentialMap;
foreignCredentials?: ICredentialMap;
} }
export interface ITagsState { export interface ITagsState {
@ -1112,7 +1126,7 @@ export type IFakeDoor = {
uiLocations: IFakeDoorLocation[], uiLocations: IFakeDoorLocation[],
}; };
export type IFakeDoorLocation = 'settings' | 'credentialsModal'; export type IFakeDoorLocation = 'settings' | 'credentialsModal' | 'workflowShareModal';
export type INodeFilterType = "Regular" | "Trigger" | "All"; export type INodeFilterType = "Regular" | "Trigger" | "All";

View file

@ -51,9 +51,3 @@ export async function oAuth2CredentialAuthorize(context: IRestApiContext, data:
export async function testCredential(context: IRestApiContext, data: INodeCredentialTestRequest): Promise<INodeCredentialTestResult> { export async function testCredential(context: IRestApiContext, data: INodeCredentialTestRequest): Promise<INodeCredentialTestResult> {
return makeRestApiRequest(context, 'POST', '/credentials/test', data as unknown as IDataObject); return makeRestApiRequest(context, 'POST', '/credentials/test', data as unknown as IDataObject);
} }
export async function getForeignCredentials(context: IRestApiContext): Promise<ICredentialsResponse[]> {
// TODO: Get foreign credentials
//return await makeRestApiRequest(context, 'GET', '/foreign-credentials');
return [];
}

View file

@ -0,0 +1,13 @@
import {
IRestApiContext,
IShareWorkflowsPayload,
IWorkflowsShareResponse,
} from '@/Interface';
import { makeRestApiRequest } from './helpers';
import {
IDataObject,
} from 'n8n-workflow';
export async function setWorkflowSharedWith(context: IRestApiContext, id: string, data: IShareWorkflowsPayload): Promise<IWorkflowsShareResponse> {
return makeRestApiRequest(context, 'PUT', `/workflows/${id}/share`, data as unknown as IDataObject);
}

View file

@ -73,7 +73,7 @@ export default mixins(
}, },
methods: { methods: {
async onAddSharee(userId: string) { async onAddSharee(userId: string) {
const sharee = this.usersStore.getUserById(userId); const sharee = { ...this.usersStore.getUserById(userId), isOwner: false };
this.$emit('change', (this.credentialData.sharedWith || []).concat(sharee)); this.$emit('change', (this.credentialData.sharedWith || []).concat(sharee));
}, },
async onRemoveSharee(userId: string) { async onRemoveSharee(userId: string) {

View file

@ -67,6 +67,15 @@
<span class="activator"> <span class="activator">
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" /> <WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" />
</span> </span>
<enterprise-edition :features="[EnterpriseEditionFeature.WorkflowSharing]">
<n8n-button
type="tertiary"
class="mr-2xs"
@click="onShareButtonClick"
>
{{ $locale.baseText('workflowDetails.share') }}
</n8n-button>
</enterprise-edition>
<SaveButton <SaveButton
type="secondary" type="secondary"
:saved="!this.isDirty && !this.isNewWorkflow" :saved="!this.isDirty && !this.isNewWorkflow"
@ -87,10 +96,12 @@ import Vue from "vue";
import mixins from "vue-typed-mixins"; import mixins from "vue-typed-mixins";
import { import {
DUPLICATE_MODAL_KEY, DUPLICATE_MODAL_KEY,
EnterpriseEditionFeature,
MAX_WORKFLOW_NAME_LENGTH, MAX_WORKFLOW_NAME_LENGTH,
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
VIEWS, WORKFLOW_MENU_ACTIONS, VIEWS, WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
} from "@/constants"; } from "@/constants";
import ShortenName from "@/components/ShortenName.vue"; import ShortenName from "@/components/ShortenName.vue";
@ -143,6 +154,7 @@ export default mixins(workflowHelpers, titleChange).extend({
tagsEditBus: new Vue(), tagsEditBus: new Vue(),
MAX_WORKFLOW_NAME_LENGTH, MAX_WORKFLOW_NAME_LENGTH,
tagsSaving: false, tagsSaving: false,
EnterpriseEditionFeature,
}; };
}, },
computed: { computed: {
@ -228,6 +240,9 @@ export default mixins(workflowHelpers, titleChange).extend({
const saved = await this.saveCurrentWorkflow({ id: currentId, name: this.workflowName, tags: this.currentWorkflowTagIds }); const saved = await this.saveCurrentWorkflow({ id: currentId, name: this.workflowName, tags: this.currentWorkflowTagIds });
if (saved) await this.settingsStore.fetchPromptsData(); if (saved) await this.settingsStore.fetchPromptsData();
}, },
onShareButtonClick() {
this.uiStore.openModal(WORKFLOW_SHARE_MODAL_KEY);
},
onTagsEditEnable() { onTagsEditEnable() {
this.$data.appliedTagIds = this.currentWorkflowTagIds; this.$data.appliedTagIds = this.currentWorkflowTagIds;
this.$data.isTagsEditEnabled = true; this.$data.isTagsEditEnabled = true;

View file

@ -82,6 +82,10 @@
<ActivationModal /> <ActivationModal />
</ModalRoot> </ModalRoot>
<ModalRoot :name="WORKFLOW_SHARE_MODAL_KEY">
<WorkflowShareModal />
</ModalRoot>
<ModalRoot :name="ONBOARDING_CALL_SIGNUP_MODAL_KEY"> <ModalRoot :name="ONBOARDING_CALL_SIGNUP_MODAL_KEY">
<OnboardingCallSignupModal /> <OnboardingCallSignupModal />
</ModalRoot> </ModalRoot>
@ -128,6 +132,7 @@ import {
VERSIONS_MODAL_KEY, VERSIONS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY, WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY, IMPORT_CURL_MODAL_KEY,
} from '@/constants'; } from '@/constants';
@ -151,6 +156,7 @@ import DeleteUserModal from "./DeleteUserModal.vue";
import ExecutionsList from "./ExecutionsList.vue"; import ExecutionsList from "./ExecutionsList.vue";
import ActivationModal from "./ActivationModal.vue"; import ActivationModal from "./ActivationModal.vue";
import ImportCurlModal from './ImportCurlModal.vue'; import ImportCurlModal from './ImportCurlModal.vue';
import WorkflowShareModal from './WorkflowShareModal.ee.vue';
export default Vue.extend({ export default Vue.extend({
name: "Modals", name: "Modals",
@ -174,6 +180,7 @@ export default Vue.extend({
UpdatesPanel, UpdatesPanel,
ValueSurvey, ValueSurvey,
WorkflowSettings, WorkflowSettings,
WorkflowShareModal,
ImportCurlModal, ImportCurlModal,
}, },
data: () => ({ data: () => ({
@ -192,6 +199,7 @@ export default Vue.extend({
TAGS_MANAGER_MODAL_KEY, TAGS_MANAGER_MODAL_KEY,
VERSIONS_MODAL_KEY, VERSIONS_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
VALUE_SURVEY_MODAL_KEY, VALUE_SURVEY_MODAL_KEY,
EXECUTIONS_MODAL_KEY, EXECUTIONS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY, WORKFLOW_ACTIVE_MODAL_KEY,

View file

@ -15,7 +15,7 @@
size="small" size="small"
color="text-dark" color="text-dark"
> >
<div v-if="isReadOnly"> <div v-if="readonly || isReadOnly">
<n8n-input <n8n-input
:value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name" :value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name"
disabled disabled
@ -94,6 +94,7 @@ export default mixins(
).extend({ ).extend({
name: 'NodeCredentials', name: 'NodeCredentials',
props: [ props: [
'readonly',
'node', // INodeUi 'node', // INodeUi
'overrideCredType', // cred type 'overrideCredType', // cred type
], ],

View file

@ -91,6 +91,7 @@
:nodeType="activeNodeType" :nodeType="activeNodeType"
:isReadOnly="readOnly || hasForeignCredential" :isReadOnly="readOnly || hasForeignCredential"
:blockUI="blockUi && showTriggerPanel" :blockUI="blockUi && showTriggerPanel"
:executable="!readOnly || hasForeignCredential"
@valueChanged="valueChanged" @valueChanged="valueChanged"
@execute="onNodeExecute" @execute="onNodeExecute"
@stopExecution="onStopExecution" @stopExecution="onStopExecution"
@ -136,6 +137,7 @@ import InputPanel from './InputPanel.vue';
import TriggerPanel from './TriggerPanel.vue'; import TriggerPanel from './TriggerPanel.vue';
import { import {
BASE_NODE_SURVEY_URL, BASE_NODE_SURVEY_URL,
EnterpriseEditionFeature,
START_NODE_TYPE, START_NODE_TYPE,
STICKY_NODE_TYPE, STICKY_NODE_TYPE,
} from '@/constants'; } from '@/constants';
@ -147,6 +149,7 @@ import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv'; import { useNDVStore } from '@/stores/ndv';
import { useNodeTypesStore } from '@/stores/nodeTypes'; import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useUIStore } from '@/stores/ui'; import { useUIStore } from '@/stores/ui';
import {useSettingsStore} from "@/stores/settings";
export default mixins( export default mixins(
externalHooks, externalHooks,
@ -188,7 +191,6 @@ export default mixins(
pinDataDiscoveryTooltipVisible: false, pinDataDiscoveryTooltipVisible: false,
avgInputRowHeight: 0, avgInputRowHeight: 0,
avgOutputRowHeight: 0, avgOutputRowHeight: 0,
hasForeignCredential: false,
}; };
}, },
mounted() { mounted() {
@ -206,6 +208,7 @@ export default mixins(
useNDVStore, useNDVStore,
useUIStore, useUIStore,
useWorkflowsStore, useWorkflowsStore,
useSettingsStore,
), ),
sessionId(): string { sessionId(): string {
return this.ndvStore.sessionId; return this.ndvStore.sessionId;
@ -365,6 +368,21 @@ export default mixins(
blockUi(): boolean { blockUi(): boolean {
return this.isWorkflowRunning || this.isExecutionWaitingForWebhook; return this.isWorkflowRunning || this.isExecutionWaitingForWebhook;
}, },
hasForeignCredential(): boolean {
const credentials = (this.activeNode || {}).credentials;
const foreignCredentials = this.credentialsStore.foreignCredentialsById;
let hasForeignCredential = false;
if (credentials && this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing)) {
Object.values(credentials).forEach((credential) => {
if (credential.id && foreignCredentials[credential.id] && !foreignCredentials[credential.id].currentUserHasAccess) {
hasForeignCredential = true;
}
});
}
return hasForeignCredential;
},
}, },
watch: { watch: {
activeNode(node: INodeUi | null) { activeNode(node: INodeUi | null) {
@ -384,8 +402,6 @@ export default mixins(
nodeSubtitle: this.getNodeSubtitle(node, this.activeNodeType, this.getCurrentWorkflow()), nodeSubtitle: this.getNodeSubtitle(node, this.activeNodeType, this.getCurrentWorkflow()),
}); });
this.checkForeignCredentials();
setTimeout(() => { setTimeout(() => {
if (this.activeNode) { if (this.activeNode) {
const outgoingConnections = this.workflowsStore.outgoingConnectionsByNodeName( const outgoingConnections = this.workflowsStore.outgoingConnectionsByNodeName(
@ -631,12 +647,6 @@ export default mixins(
input_node_type: this.inputNode ? this.inputNode.type : '', input_node_type: this.inputNode ? this.inputNode.type : '',
}); });
}, },
checkForeignCredentials() {
if(this.activeNode){
const issues = this.getNodeCredentialIssues(this.activeNode);
this.hasForeignCredential = !!issues?.credentials?.foreign;
}
},
onStopExecution(){ onStopExecution(){
this.$emit('stopExecution'); this.$emit('stopExecution');
}, },

View file

@ -10,7 +10,7 @@
:isReadOnly="isReadOnly" :isReadOnly="isReadOnly"
@input="nameChanged" @input="nameChanged"
></NodeTitle> ></NodeTitle>
<div v-if="!isReadOnly"> <div v-if="executable">
<NodeExecuteButton <NodeExecuteButton
v-if="!blockUI" v-if="!blockUI"
:nodeName="node.name" :nodeName="node.name"
@ -83,7 +83,7 @@
@valueChanged="valueChanged" @valueChanged="valueChanged"
@activate="onWorkflowActivate" @activate="onWorkflowActivate"
> >
<node-credentials :node="node" @credentialSelected="credentialSelected" /> <node-credentials :node="node" :readonly="isReadOnly" @credentialSelected="credentialSelected" />
</parameter-input-list> </parameter-input-list>
<div v-if="parametersNoneSetting.length === 0" class="no-parameters"> <div v-if="parametersNoneSetting.length === 0" class="no-parameters">
<n8n-text> <n8n-text>
@ -264,6 +264,10 @@ export default mixins(externalHooks, nodeHelpers).extend({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
executable: {
type: Boolean,
default: true,
},
}, },
data() { data() {
return { return {

View file

@ -26,9 +26,9 @@
</div> </div>
<template #append> <template #append>
<div :class="$style.cardActions"> <div :class="$style.cardActions">
<enterprise-edition :features="[EnterpriseEditionFeature.Sharing]" v-show="false"> <enterprise-edition :features="[EnterpriseEditionFeature.WorkflowSharing]">
<n8n-badge <n8n-badge
v-if="credentialPermissions.isOwner" v-if="workflowPermissions.isOwner"
class="mr-xs" class="mr-xs"
theme="tertiary" theme="tertiary"
bold bold
@ -122,7 +122,7 @@ export default mixins(
currentUser (): IUser { currentUser (): IUser {
return this.usersStore.currentUser || {} as IUser; return this.usersStore.currentUser || {} as IUser;
}, },
credentialPermissions(): IPermissions { workflowPermissions(): IPermissions {
return getWorkflowPermissions(this.currentUser, this.data); return getWorkflowPermissions(this.currentUser, this.data);
}, },
actions(): Array<{ label: string; value: string; }> { actions(): Array<{ label: string; value: string; }> {
@ -135,7 +135,7 @@ export default mixins(
label: this.$locale.baseText('workflows.item.duplicate'), label: this.$locale.baseText('workflows.item.duplicate'),
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE, value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
}, },
].concat(this.credentialPermissions.delete ? [{ ].concat(this.workflowPermissions.delete ? [{
label: this.$locale.baseText('workflows.item.delete'), label: this.$locale.baseText('workflows.item.delete'),
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE, value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
}]: []); }]: []);

View file

@ -0,0 +1,265 @@
<template>
<Modal
width="460px"
:title="$locale.baseText(fakeDoor.actionBoxTitle, { interpolate: { name: workflow.name } })"
:eventBus="modalBus"
:name="WORKFLOW_SHARE_MODAL_KEY"
:center="true"
>
<template slot="content">
<div :class="$style.container">
<enterprise-edition :features="[EnterpriseEditionFeature.WorkflowSharing]">
<n8n-user-select
v-if="workflowPermissions.updateSharing"
size="large"
:users="usersList"
:currentUserId="currentUser.id"
:placeholder="$locale.baseText('workflows.shareModal.select.placeholder')"
@input="onAddSharee"
>
<template #prefix>
<n8n-icon icon="search" />
</template>
</n8n-user-select>
<n8n-users-list
:actions="[]"
:users="sharedWithList"
:currentUserId="currentUser.id"
:delete-label="$locale.baseText('workflows.shareModal.list.delete')"
:readonly="!workflowPermissions.updateSharing"
@delete="onRemoveSharee"
>
<template v-slot:actions="{ user }">
<n8n-select
:class="$style.roleSelect"
value="editor"
size="small"
@change="onRoleAction(user, $event)"
>
<n8n-option
:label="$locale.baseText('workflows.roles.editor')"
value="editor" />
<n8n-option
:class="$style.roleSelectRemoveOption"
value="remove"
>
<n8n-text color="danger">{{ $locale.baseText('workflows.shareModal.list.delete') }}</n8n-text>
</n8n-option>
</n8n-select>
</template>
</n8n-users-list>
<template #fallback>
<n8n-text>
{{ $locale.baseText(fakeDoor.actionBoxDescription) }}
</n8n-text>
</template>
</enterprise-edition>
</div>
</template>
<template slot="footer">
<enterprise-edition :features="[EnterpriseEditionFeature.WorkflowSharing]" :class="$style.actionButtons">
<n8n-text
v-show="isDirty"
color="text-light"
size="small"
class="mr-xs"
>
{{ $locale.baseText('workflows.shareModal.changesHint') }}
</n8n-text>
<n8n-button
v-show="workflowPermissions.updateSharing"
@click="onSave"
:loading="loading"
:disabled="!isDirty"
size="medium"
>
{{ $locale.baseText('workflows.shareModal.save') }}
</n8n-button>
<template #fallback>
<n8n-link :to="fakeDoor.linkURL">
<n8n-button
:loading="loading"
size="medium"
>
{{ $locale.baseText(fakeDoor.actionBoxButtonLabel) }}
</n8n-button>
</n8n-link>
</template>
</enterprise-edition>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import Modal from './Modal.vue';
import {
EnterpriseEditionFeature,
FAKE_DOOR_FEATURES,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
WORKFLOW_SHARE_MODAL_KEY
} from '../constants';
import {IFakeDoor, IUser, IWorkflowDb} from "@/Interface";
import { getWorkflowPermissions, IPermissions } from "@/permissions";
import mixins from "vue-typed-mixins";
import {showMessage} from "@/components/mixins/showMessage";
import {nodeViewEventBus} from "@/event-bus/node-view-event-bus";
import {mapStores} from "pinia";
import {useSettingsStore} from "@/stores/settings";
import {useUIStore} from "@/stores/ui";
import {useUsersStore} from "@/stores/users";
import {useWorkflowsStore} from "@/stores/workflows";
import useWorkflowsEEStore from "@/stores/workflows.ee";
export default mixins(
showMessage,
).extend({
name: 'workflow-share-modal',
components: {
Modal,
},
data() {
const workflowsStore = useWorkflowsStore();
return {
WORKFLOW_SHARE_MODAL_KEY,
loading: false,
modalBus: new Vue(),
sharedWith: [...(workflowsStore.workflow.sharedWith || [])] as Array<Partial<IUser>>,
EnterpriseEditionFeature,
};
},
computed: {
...mapStores(useSettingsStore, useUIStore, useUsersStore, useWorkflowsStore, useWorkflowsEEStore),
usersList(): IUser[] {
return this.usersStore.allUsers.filter((user: IUser) => {
const isCurrentUser = user.id === this.usersStore.currentUser?.id;
const isAlreadySharedWithUser = (this.sharedWith || []).find((sharee) => sharee.id === user.id);
return !isCurrentUser && !isAlreadySharedWithUser;
});
},
sharedWithList(): Array<Partial<IUser>> {
return ([
{
...(this.workflow && this.workflow.ownedBy ? this.workflow.ownedBy : this.usersStore.currentUser),
isOwner: true,
},
] as Array<Partial<IUser>>).concat(this.sharedWith || []);
},
workflow(): IWorkflowDb {
return this.workflowsStore.workflow;
},
currentUser(): IUser | null {
return this.usersStore.currentUser;
},
workflowPermissions(): IPermissions {
return getWorkflowPermissions(this.usersStore.currentUser, this.workflow);
},
isSharingAvailable(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing) === true;
},
fakeDoor(): IFakeDoor | undefined {
return this.uiStore.getFakeDoorById(FAKE_DOOR_FEATURES.WORKFLOWS_SHARING);
},
isDirty(): boolean {
const previousSharedWith = this.workflow.sharedWith || [];
return this.sharedWith.length !== previousSharedWith.length ||
this.sharedWith.some(
(sharee) => !previousSharedWith.find((previousSharee) => sharee.id === previousSharee.id),
);
},
},
methods: {
async onSave() {
if (this.loading) {
return;
}
this.loading = true;
const saveWorkflowPromise = () => {
return new Promise<string>((resolve) => {
if (this.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
nodeViewEventBus.$emit('saveWorkflow', () => {
resolve(this.workflowsStore.workflowId);
});
} else {
resolve(this.workflowsStore.workflowId);
}
});
};
const workflowId = await saveWorkflowPromise();
await this.workflowsEEStore.saveWorkflowSharedWith({ workflowId, sharedWith: this.sharedWith });
this.loading = false;
},
async onAddSharee(userId: string) {
const { id, firstName, lastName, email } = this.usersStore.getUserById(userId)!;
const sharee = { id, firstName, lastName, email };
this.sharedWith = this.sharedWith.concat(sharee);
},
async onRemoveSharee(userId: string) {
const user = this.usersStore.getUserById(userId)!;
const isNewSharee = !(this.workflow.sharedWith || []).find((sharee) => sharee.id === userId);
let confirm = true;
if (!isNewSharee) {
confirm = await this.confirmMessage(
this.$locale.baseText('workflows.shareModal.list.delete.confirm.message', {
interpolate: { name: user.fullName as string, workflow: this.workflow.name },
}),
this.$locale.baseText('workflows.shareModal.list.delete.confirm.title', { interpolate: { name: user.fullName } }),
null,
this.$locale.baseText('workflows.shareModal.list.delete.confirm.confirmButtonText'),
this.$locale.baseText('workflows.shareModal.list.delete.confirm.cancelButtonText'),
);
}
if (confirm) {
this.sharedWith = this.sharedWith.filter((sharee: IUser) => {
return sharee.id !== user.id;
});
}
},
onRoleAction(user: IUser, action: string) {
if (action === 'remove') {
this.onRemoveSharee(user.id);
}
},
async loadUsers() {
await this.usersStore.fetchUsers();
},
},
mounted() {
if (this.isSharingAvailable) {
this.loadUsers();
}
},
});
</script>
<style module lang="scss">
.container > * {
margin-bottom: var(--spacing-s);
overflow-wrap: break-word;
}
.actionButtons {
display: flex;
justify-content: flex-end;
align-items: center;
}
.roleSelect {
max-width: 100px;
}
.roleSelectRemoveOption {
border-top: 1px solid var(--color-foreground-base);
}
</style>

View file

@ -245,16 +245,6 @@ export const nodeHelpers = mixins(
let credentialType: ICredentialType | null; let credentialType: ICredentialType | null;
let credentialDisplayName: string; let credentialDisplayName: string;
let selectedCredentials: INodeCredentialsDetails; let selectedCredentials: INodeCredentialsDetails;
const foreignCredentials = this.credentialsStore.allForeignCredentials;
// TODO: Check if any of the node credentials is found in foreign credentials
if(foreignCredentials?.some(() => true)){
return {
credentials: {
foreign: [],
},
};
}
const { const {
authentication, authentication,
@ -350,9 +340,7 @@ export const nodeHelpers = mixins(
} }
if (nameMatches.length === 0) { if (nameMatches.length === 0) {
if (this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) { if (!this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing)) {
foundIssues[credentialTypeDescription.name] = [this.$locale.baseText('nodeIssues.credentials.notAvailable')];
} else {
foundIssues[credentialTypeDescription.name] = [this.$locale.baseText('nodeIssues.credentials.doNotExist', { interpolate: { name: selectedCredentials.name, type: credentialDisplayName } }), this.$locale.baseText('nodeIssues.credentials.doNotExist.hint')]; foundIssues[credentialTypeDescription.name] = [this.$locale.baseText('nodeIssues.credentials.doNotExist', { interpolate: { name: selectedCredentials.name, type: credentialDisplayName } }), this.$locale.baseText('nodeIssues.credentials.doNotExist.hint')];
} }
} }

View file

@ -383,9 +383,10 @@ export const pushConnection = mixins(
// it can be displayed in the node-view // it can be displayed in the node-view
this.updateNodesExecutionIssues(); this.updateNodesExecutionIssues();
const lastNodeExecuted: string | undefined = runDataExecuted.data.resultData.lastNodeExecuted;
let itemsCount = 0; let itemsCount = 0;
if(runDataExecuted.data.resultData.lastNodeExecuted && !runDataExecutedErrorMessage) { if(lastNodeExecuted && runDataExecuted.data.resultData.runData[lastNodeExecuted as string] && !runDataExecutedErrorMessage) {
itemsCount = runDataExecuted.data.resultData.runData[runDataExecuted.data.resultData.lastNodeExecuted][0].data!.main[0]!.length; itemsCount = runDataExecuted.data.resultData.runData[lastNodeExecuted as string][0].data!.main[0]!.length;
} }
this.$externalHooks().run('pushConnection.executionFinished', { this.$externalHooks().run('pushConnection.executionFinished', {

View file

@ -3,7 +3,7 @@ import { ElNotificationComponent, ElNotificationOptions } from 'element-ui/types
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
import { IRunExecutionData } from 'n8n-workflow'; import {IExecuteContextData, IRunExecutionData} from 'n8n-workflow';
import type { ElMessageBoxOptions } from 'element-ui/types/message-box'; import type { ElMessageBoxOptions } from 'element-ui/types/message-box';
import type { ElMessageComponent, ElMessageOptions, MessageType } from 'element-ui/types/message'; import type { ElMessageComponent, ElMessageOptions, MessageType } from 'element-ui/types/message';
import { sanitizeHtml } from '@/utils'; import { sanitizeHtml } from '@/utils';
@ -89,13 +89,13 @@ export const showMessage = mixins(externalHooks).extend({
return this.$message(config); return this.$message(config);
}, },
$getExecutionError(data: IRunExecutionData) { $getExecutionError(data: IRunExecutionData | IExecuteContextData) {
const error = data.resultData.error; const error = data.resultData.error;
let errorMessage: string; let errorMessage: string;
if (data.resultData.lastNodeExecuted && error) { if (data.resultData.lastNodeExecuted && error) {
errorMessage = error.message; errorMessage = error.message || error.description;
} else { } else {
errorMessage = 'There was a problem executing the workflow!'; errorMessage = 'There was a problem executing the workflow!';

View file

@ -32,6 +32,7 @@ export const DUPLICATE_MODAL_KEY = 'duplicate';
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager'; export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const VERSIONS_MODAL_KEY = 'versions'; export const VERSIONS_MODAL_KEY = 'versions';
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings'; export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare';
export const PERSONALIZATION_MODAL_KEY = 'personalization'; export const PERSONALIZATION_MODAL_KEY = 'personalization';
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt'; export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey'; export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey';
@ -315,7 +316,8 @@ export enum VIEWS {
export enum FAKE_DOOR_FEATURES { export enum FAKE_DOOR_FEATURES {
ENVIRONMENTS = 'environments', ENVIRONMENTS = 'environments',
LOGGING = 'logging', LOGGING = 'logging',
SHARING = 'sharing', CREDENTIALS_SHARING = 'credentialsSharing',
WORKFLOWS_SHARING = 'workflowsSharing',
} }
export const ONBOARDING_PROMPT_TIMEBOX = 14; export const ONBOARDING_PROMPT_TIMEBOX = 14;
@ -370,6 +372,7 @@ export enum WORKFLOW_MENU_ACTIONS {
*/ */
export enum EnterpriseEditionFeature { export enum EnterpriseEditionFeature {
Sharing = 'sharing', Sharing = 'sharing',
WorkflowSharing = 'workflowSharing',
} }
export const MAIN_NODE_PANEL_WIDTH = 360; export const MAIN_NODE_PANEL_WIDTH = 360;
@ -418,6 +421,7 @@ export enum STORES {
UI = 'ui', UI = 'ui',
USERS = 'users', USERS = 'users',
WORKFLOWS = 'workflows', WORKFLOWS = 'workflows',
WORKFLOWS_EE = 'workflowsEE',
NDV = 'ndv', NDV = 'ndv',
TEMPLATES = 'templates', TEMPLATES = 'templates',
NODE_TYPES = 'nodeTypes', NODE_TYPES = 'nodeTypes',

View file

@ -0,0 +1,3 @@
import Vue from 'vue';
export const nodeViewEventBus = new Vue();

View file

@ -5,7 +5,7 @@
*/ */
import {IUser, ICredentialsResponse, IRootState, IWorkflowDb} from "@/Interface"; import {IUser, ICredentialsResponse, IRootState, IWorkflowDb} from "@/Interface";
import {EnterpriseEditionFeature} from "@/constants"; import {EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID} from "@/constants";
import { useSettingsStore } from "./stores/settings"; import { useSettingsStore } from "./stores/settings";
export enum UserRole { export enum UserRole {
@ -32,9 +32,9 @@ export type IPermissionsTable = IPermissionsTableRow[];
* @param user * @param user
* @param table * @param table
*/ */
export const parsePermissionsTable = (user: IUser, table: IPermissionsTable): IPermissions => { export const parsePermissionsTable = (user: IUser | null, table: IPermissionsTable): IPermissions => {
const genericTable = [ const genericTable = [
{ name: UserRole.InstanceOwner, test: () => user.isOwner }, { name: UserRole.InstanceOwner, test: () => user?.isOwner },
]; ];
return [ return [
@ -53,11 +53,11 @@ export const parsePermissionsTable = (user: IUser, table: IPermissionsTable): IP
* User permissions definition * User permissions definition
*/ */
export const getCredentialPermissions = (user: IUser, credential: ICredentialsResponse) => { export const getCredentialPermissions = (user: IUser | null, credential: ICredentialsResponse) => {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const table: IPermissionsTable = [ const table: IPermissionsTable = [
{ name: UserRole.ResourceOwner, test: () => !!(credential && credential.ownedBy && credential.ownedBy.id === user.id) || !settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing) }, { name: UserRole.ResourceOwner, test: () => !!(credential && credential.ownedBy && credential.ownedBy.id === user?.id) || !settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing) },
{ name: UserRole.ResourceReader, test: () => !!(credential && credential.sharedWith && credential.sharedWith.find((sharee) => sharee.id === user.id)) }, { name: UserRole.ResourceReader, test: () => !!(credential && credential.sharedWith && credential.sharedWith.find((sharee) => sharee.id === user?.id)) },
{ name: 'read', test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceReader] }, { name: 'read', test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceReader] },
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] }, { name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] }, { name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
@ -71,12 +71,13 @@ export const getCredentialPermissions = (user: IUser, credential: ICredentialsRe
return parsePermissionsTable(user, table); return parsePermissionsTable(user, table);
}; };
export const getWorkflowPermissions = (user: IUser, workflow: IWorkflowDb) => { export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb) => {
const settingsStore = useSettingsStore();
const isNewWorkflow = workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID;
const table: IPermissionsTable = [ const table: IPermissionsTable = [
// { name: UserRole.ResourceOwner, test: () => !!(workflow && workflow.ownedBy && workflow.ownedBy.id === user.id) || !useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing) }, { name: UserRole.ResourceOwner, test: () => !!(isNewWorkflow || workflow && workflow.ownedBy && workflow.ownedBy.id === user?.id) || !settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing) },
{ name: UserRole.ResourceOwner, test: () => true }, { name: UserRole.ResourceReader, test: () => !!(workflow && workflow.sharedWith && workflow.sharedWith.find((sharee) => sharee.id === user?.id)) },
// { name: UserRole.ResourceReader, test: () => !!(workflow && workflow.sharedWith && workflow.sharedWith.find((sharee) => sharee.id === user.id)) },
{ name: UserRole.ResourceReader, test: () => true },
{ name: 'read', test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceReader] }, { name: 'read', test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceReader] },
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] }, { name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] }, { name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },

View file

@ -486,6 +486,11 @@
"fakeDoor.credentialEdit.sharing.actionBox.title.cloud.upgrade": "Upgrade to add users", "fakeDoor.credentialEdit.sharing.actionBox.title.cloud.upgrade": "Upgrade to add users",
"fakeDoor.credentialEdit.sharing.actionBox.description.cloud.upgrade": "Power and Pro plan users can create multiple user accounts and share credentials. (Sharing workflows is coming soon)", "fakeDoor.credentialEdit.sharing.actionBox.description.cloud.upgrade": "Power and Pro plan users can create multiple user accounts and share credentials. (Sharing workflows is coming soon)",
"fakeDoor.credentialEdit.sharing.actionBox.button.cloud.upgrade": "Upgrade", "fakeDoor.credentialEdit.sharing.actionBox.button.cloud.upgrade": "Upgrade",
"fakeDoor.workflowsSharing.title.cloud.upgrade": "Upgrade to add users",
"fakeDoor.workflowsSharing.description": "Sharing workflows with others is currently available only on n8n cloud, our hosted offering.",
"fakeDoor.workflowsSharing.description.cloud.upgrade": "Power and Pro plan users can create multiple user accounts and share workflows.",
"fakeDoor.workflowsSharing.button": "Explore n8n cloud",
"fakeDoor.workflowsSharing.button.cloud.upgrade": "Upgrade",
"fakeDoor.settings.environments.name": "Environments", "fakeDoor.settings.environments.name": "Environments",
"fakeDoor.settings.environments.infoText": "Environments allow you to use different settings and credentials in a workflow when you're building it vs when it's running in production", "fakeDoor.settings.environments.infoText": "Environments allow you to use different settings and credentials in a workflow when you're building it vs when it's running in production",
"fakeDoor.settings.environments.actionBox.title": "Were working on environments (as a paid feature)", "fakeDoor.settings.environments.actionBox.title": "Were working on environments (as a paid feature)",
@ -1247,6 +1252,7 @@
"workflowActivator.showMessage.displayActivationError.title": "Problem activating workflow", "workflowActivator.showMessage.displayActivationError.title": "Problem activating workflow",
"workflowActivator.theWorkflowIsSetToBeActiveBut": "The workflow is activated but could not be started.<br />Click to display error message.", "workflowActivator.theWorkflowIsSetToBeActiveBut": "The workflow is activated but could not be started.<br />Click to display error message.",
"workflowActivator.thisWorkflowHasNoTriggerNodes": "This workflow has no trigger nodes that require activation", "workflowActivator.thisWorkflowHasNoTriggerNodes": "This workflow has no trigger nodes that require activation",
"workflowDetails.share": "Share",
"workflowDetails.active": "Active", "workflowDetails.active": "Active",
"workflowDetails.addTag": "Add tag", "workflowDetails.addTag": "Add tag",
"workflowDetails.chooseOrCreateATag": "Choose or create a tag", "workflowDetails.chooseOrCreateATag": "Choose or create a tag",
@ -1361,6 +1367,18 @@
"workflows.empty.description": "Create your first workflow", "workflows.empty.description": "Create your first workflow",
"workflows.empty.startFromScratch": "Start from scratch", "workflows.empty.startFromScratch": "Start from scratch",
"workflows.empty.browseTemplates": "Browse templates", "workflows.empty.browseTemplates": "Browse templates",
"workflows.shareModal.title": "Share '{name}'",
"workflows.shareModal.select.placeholder": "Add people",
"workflows.shareModal.list.delete": "Remove access",
"workflows.shareModal.list.delete.confirm.title": "Remove {name}'s access?",
"workflows.shareModal.list.delete.confirm.message": "<strong>This might cause the workflow to stop working:</strong> if {name} is the only user with access to credentials used in this workflow, those credentials will also be removed from {workflow}.",
"workflows.shareModal.list.delete.confirm.confirmButtonText": "Remove access",
"workflows.shareModal.list.delete.confirm.cancelButtonText": "Cancel",
"workflows.shareModal.save": "Save",
"workflows.shareModal.changesHint": "You made changes",
"workflows.shareModal.notAvailable": "Sharing workflows with others is currently available only on n8n cloud, our hosted offering.",
"workflows.shareModal.notAvailable.button": "Explore n8n cloud",
"workflows.roles.editor": "Editor",
"importCurlModal.title": "Import cURL command", "importCurlModal.title": "Import cURL command",
"importCurlModal.input.label": "cURL Command", "importCurlModal.input.label": "cURL Command",
"importCurlModal.input.placeholder": "Paste the cURL command here", "importCurlModal.input.placeholder": "Paste the cURL command here",

View file

@ -1,4 +1,4 @@
import { createNewCredential, deleteCredential, getAllCredentials, getCredentialData, getCredentialsNewName, getCredentialTypes, getForeignCredentials, oAuth1CredentialAuthorize, oAuth2CredentialAuthorize, testCredential, updateCredential } from "@/api/credentials"; import { createNewCredential, deleteCredential, getAllCredentials, getCredentialData, getCredentialsNewName, getCredentialTypes, oAuth1CredentialAuthorize, oAuth2CredentialAuthorize, testCredential, updateCredential } from "@/api/credentials";
import { setCredentialSharedWith } from "@/api/credentials.ee"; import { setCredentialSharedWith } from "@/api/credentials.ee";
import { getAppNameFromCredType } from "@/components/helpers"; import { getAppNameFromCredType } from "@/components/helpers";
import { EnterpriseEditionFeature, STORES } from "@/constants"; import { EnterpriseEditionFeature, STORES } from "@/constants";
@ -33,10 +33,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
return Object.values(this.credentials) return Object.values(this.credentials)
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
}, },
allForeignCredentials(): ICredentialsResponse[] {
return Object.values(this.foreignCredentials || {})
.sort((a, b) => a.name.localeCompare(b.name));
},
allCredentialsByType(): {[type: string]: ICredentialsResponse[]} { allCredentialsByType(): {[type: string]: ICredentialsResponse[]} {
const credentials = this.allCredentials; const credentials = this.allCredentials;
const types = this.allCredentialTypes; const types = this.allCredentialTypes;
@ -53,6 +49,9 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
getCredentialById() { getCredentialById() {
return (id: string): ICredentialsResponse => this.credentials[id]; return (id: string): ICredentialsResponse => this.credentials[id];
}, },
foreignCredentialsById(): ICredentialMap {
return Object.fromEntries(Object.entries(this.credentials).filter(([_, credential]) => credential.hasOwnProperty('currentUserHasAccess')));
},
getCredentialByIdAndType() { getCredentialByIdAndType() {
return (id: string, type: string): ICredentialsResponse | undefined => { return (id: string, type: string): ICredentialsResponse | undefined => {
const credential = this.credentials[id]; const credential = this.credentials[id];
@ -138,13 +137,12 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
return accu; return accu;
}, {}); }, {});
}, },
setForeignCredentials(credentials: ICredentialsResponse[]): void { addCredentials(credentials: ICredentialsResponse[]): void {
this.foreignCredentials = credentials.reduce((accu: ICredentialMap, cred: ICredentialsResponse) => { credentials.forEach((cred: ICredentialsResponse) => {
if (cred.id) { if (cred.id) {
accu[cred.id] = cred; this.credentials[cred.id] = { ...this.credentials[cred.id], ...cred };
} }
return accu; });
}, {});
}, },
upsertCredential(credential: ICredentialsResponse): void { upsertCredential(credential: ICredentialsResponse): void {
if (credential.id) { if (credential.id) {
@ -168,12 +166,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
this.setCredentials(credentials); this.setCredentials(credentials);
return credentials; return credentials;
}, },
async fetchForeignCredentials(): Promise<ICredentialsResponse[]> {
const rootStore = useRootStore();
const credentials = await getForeignCredentials(rootStore.getRestApiContext);
this.setForeignCredentials(credentials);
return credentials;
},
async getCredentialData({ id }: {id: string}): Promise<ICredentialsResponse | ICredentialsDecryptedResponse | undefined> { async getCredentialData({ id }: {id: string}): Promise<ICredentialsResponse | ICredentialsDecryptedResponse | undefined> {
const rootStore = useRootStore(); const rootStore = useRootStore();
return await getCredentialData(rootStore.getRestApiContext, id); return await getCredentialData(rootStore.getRestApiContext, id);

View file

@ -26,7 +26,7 @@ import {
VERSIONS_MODAL_KEY, VERSIONS_MODAL_KEY,
VIEWS, VIEWS,
WORKFLOW_ACTIVE_MODAL_KEY, WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY,
} from "@/constants"; } from "@/constants";
import { import {
CurlToJSONResponse, CurlToJSONResponse,
@ -97,6 +97,9 @@ export const useUIStore = defineStore(STORES.UI, {
[EXECUTIONS_MODAL_KEY]: { [EXECUTIONS_MODAL_KEY]: {
open: false, open: false,
}, },
[WORKFLOW_SHARE_MODAL_KEY]: {
open: false,
},
[WORKFLOW_ACTIVE_MODAL_KEY]: { [WORKFLOW_ACTIVE_MODAL_KEY]: {
open: false, open: false,
}, },
@ -141,13 +144,22 @@ export const useUIStore = defineStore(STORES.UI, {
uiLocations: ['settings'], uiLocations: ['settings'],
}, },
{ {
id: FAKE_DOOR_FEATURES.SHARING, id: FAKE_DOOR_FEATURES.CREDENTIALS_SHARING,
featureName: 'fakeDoor.credentialEdit.sharing.name', featureName: 'fakeDoor.credentialEdit.sharing.name',
actionBoxTitle: 'fakeDoor.credentialEdit.sharing.actionBox.title', actionBoxTitle: 'fakeDoor.credentialEdit.sharing.actionBox.title',
actionBoxDescription: 'fakeDoor.credentialEdit.sharing.actionBox.description', actionBoxDescription: 'fakeDoor.credentialEdit.sharing.actionBox.description',
linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=sharing', linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=sharing',
uiLocations: ['credentialsModal'], uiLocations: ['credentialsModal'],
}, },
{
id: FAKE_DOOR_FEATURES.WORKFLOWS_SHARING,
featureName: 'fakeDoor.workflowsSharing.name',
actionBoxTitle: 'workflows.shareModal.title', // Use this translation in modal title when removing fakeDoor
actionBoxDescription: 'fakeDoor.workflowsSharing.description',
actionBoxButtonLabel: 'fakeDoor.workflowsSharing.button',
linkURL: 'https://n8n.cloud',
uiLocations: ['workflowShareModal'],
},
], ],
draggable: { draggable: {
isDragging: false, isDragging: false,

View file

@ -23,8 +23,8 @@ export const useUsersStore = defineStore(STORES.USERS, {
currentUser(): IUser | null { currentUser(): IUser | null {
return this.currentUserId ? this.users[this.currentUserId] : null; return this.currentUserId ? this.users[this.currentUserId] : null;
}, },
getUserById(): (userId: string) => IUser | null { getUserById(state) {
return (userId: string): IUser | null => this.users[userId]; return (userId: string): IUser | null => state.users[userId];
}, },
globalRoleName(): string { globalRoleName(): string {
return this.currentUser?.globalRole?.name || ''; return this.currentUser?.globalRole?.name || '';

View file

@ -0,0 +1,67 @@
import Vue from 'vue';
import {
IUser,
} from '../Interface';
import {setWorkflowSharedWith} from "@/api/workflows.ee";
import {EnterpriseEditionFeature, STORES} from "@/constants";
import {useRootStore} from "@/stores/n8nRootStore";
import {useSettingsStore} from "@/stores/settings";
import {defineStore} from "pinia";
import {useWorkflowsStore} from "@/stores/workflows";
// @TODO Move to workflows store as part of workflows store refactoring
//
export const useWorkflowsEEStore = defineStore(STORES.WORKFLOWS_EE, {
state() { return {}; },
actions: {
setWorkflowOwnedBy(payload: { workflowId: string, ownedBy: Partial<IUser> }): void {
const workflowsStore = useWorkflowsStore();
Vue.set(workflowsStore.workflowsById[payload.workflowId], 'ownedBy', payload.ownedBy);
Vue.set(workflowsStore.workflow, 'ownedBy', payload.ownedBy);
},
setWorkflowSharedWith(payload: { workflowId: string, sharedWith: Array<Partial<IUser>> }): void {
const workflowsStore = useWorkflowsStore();
Vue.set(workflowsStore.workflowsById[payload.workflowId], 'sharedWith', payload.sharedWith);
Vue.set(workflowsStore.workflow, 'sharedWith', payload.sharedWith);
},
addWorkflowSharee(payload: { workflowId: string, sharee: Partial<IUser> }): void {
const workflowsStore = useWorkflowsStore();
Vue.set(
workflowsStore.workflowsById[payload.workflowId],
'sharedWith',
(workflowsStore.workflowsById[payload.workflowId].sharedWith || []).concat([payload.sharee]),
);
},
removeWorkflowSharee(payload: { workflowId: string, sharee: Partial<IUser> }): void {
const workflowsStore = useWorkflowsStore();
Vue.set(
workflowsStore.workflowsById[payload.workflowId],
'sharedWith',
(workflowsStore.workflowsById[payload.workflowId].sharedWith || [])
.filter((sharee) => sharee.id !== payload.sharee.id),
);
},
async saveWorkflowSharedWith(payload: { sharedWith: Array<Partial<IUser>>; workflowId: string; }): Promise<void> {
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing)) {
await setWorkflowSharedWith(
rootStore.getRestApiContext,
payload.workflowId,
{
shareWithIds: payload.sharedWith.map((sharee) => sharee.id as string),
},
);
this.setWorkflowSharedWith(payload);
}
},
},
});
export default useWorkflowsEEStore;

View file

@ -290,7 +290,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
if (this.workflowsById[workflowId]) { if (this.workflowsById[workflowId]) {
this.workflowsById[workflowId].active = true; this.workflowsById[workflowId].active = true;
} }
}, },
setWorkflowInactive(workflowId: string): void { setWorkflowInactive(workflowId: string): void {

View file

@ -149,7 +149,7 @@ import {
STICKY_NODE_TYPE, STICKY_NODE_TYPE,
VIEWS, VIEWS,
WEBHOOK_NODE_TYPE, WEBHOOK_NODE_TYPE,
TRIGGER_NODE_FILTER, TRIGGER_NODE_FILTER, EnterpriseEditionFeature,
} from '@/constants'; } from '@/constants';
import { copyPaste } from '@/components/mixins/copyPaste'; import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
@ -221,6 +221,7 @@ import { useSettingsStore } from '@/stores/settings';
import { useUsersStore } from '@/stores/users'; import { useUsersStore } from '@/stores/users';
import { getNodeViewTab } from '@/components/helpers'; import { getNodeViewTab } from '@/components/helpers';
import { Route, RawLocation } from 'vue-router'; import { Route, RawLocation } from 'vue-router';
import { nodeViewEventBus } from '@/event-bus/node-view-event-bus';
import { useWorkflowsStore } from '@/stores/workflows'; import { useWorkflowsStore } from '@/stores/workflows';
import { useRootStore } from '@/stores/n8nRootStore'; import { useRootStore } from '@/stores/n8nRootStore';
import { useNDVStore } from '@/stores/ndv'; import { useNDVStore } from '@/stores/ndv';
@ -231,6 +232,7 @@ import { useTagsStore } from '@/stores/tags';
import { useNodeCreatorStore } from '@/stores/nodeCreator'; import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { dataPinningEventBus } from '@/event-bus/data-pinning-event-bus'; import { dataPinningEventBus } from '@/event-bus/data-pinning-event-bus';
import { useCanvasStore } from '@/stores/canvas'; import { useCanvasStore } from '@/stores/canvas';
import useWorkflowsEEStore from "@/stores/workflows.ee";
interface AddNodeOptions { interface AddNodeOptions {
position?: XYPosition; position?: XYPosition;
@ -395,6 +397,7 @@ export default mixins(
useUIStore, useUIStore,
useUsersStore, useUsersStore,
useWorkflowsStore, useWorkflowsStore,
useWorkflowsEEStore,
), ),
nativelyNumberSuffixedDefaults(): string[] { nativelyNumberSuffixedDefaults(): string[] {
return this.rootStore.nativelyNumberSuffixedDefaults; return this.rootStore.nativelyNumberSuffixedDefaults;
@ -830,6 +833,7 @@ export default mixins(
), ),
); );
} }
this.workflowsStore.setActive(data.active || false); this.workflowsStore.setActive(data.active || false);
this.workflowsStore.setWorkflowId(workflowId); this.workflowsStore.setWorkflowId(workflowId);
this.workflowsStore.setWorkflowName({ newName: data.name, setStateDirty: false }); this.workflowsStore.setWorkflowName({ newName: data.name, setStateDirty: false });
@ -837,6 +841,32 @@ export default mixins(
this.workflowsStore.setWorkflowPinData(data.pinData || {}); this.workflowsStore.setWorkflowPinData(data.pinData || {});
this.workflowsStore.setWorkflowHash(data.hash); this.workflowsStore.setWorkflowHash(data.hash);
// @TODO
this.workflowsStore.addWorkflow({
id: data.id,
name: data.name,
ownedBy: data.ownedBy,
sharedWith: data.sharedWith,
tags: data.tags || [],
active: data.active,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
nodes: data.nodes,
connections: data.connections,
});
this.workflowsEEStore.setWorkflowOwnedBy({
workflowId: data.id,
ownedBy: data.ownedBy,
});
this.workflowsEEStore.setWorkflowSharedWith({
workflowId: data.id,
sharedWith: data.sharedWith,
});
if (data.usedCredentials) {
this.credentialsStore.addCredentials(data.usedCredentials);
}
const tags = (data.tags || []) as ITag[]; const tags = (data.tags || []) as ITag[];
const tagIds = tags.map((tag) => tag.id); const tagIds = tags.map((tag) => tag.id);
this.workflowsStore.setWorkflowTagIds(tagIds || []); this.workflowsStore.setWorkflowTagIds(tagIds || []);
@ -2372,6 +2402,15 @@ export default mixins(
newNodeData.webhookId = uuid(); newNodeData.webhookId = uuid();
} }
if (newNodeData.credentials && this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing)) {
const foreignCredentials = this.credentialsStore.foreignCredentialsById;
newNodeData.credentials = Object.fromEntries(
Object.entries(newNodeData.credentials).filter(([_, credential]) => {
return credential.id && (!foreignCredentials[credential.id] || foreignCredentials[credential.id]?.currentUserHasAccess);
}),
);
}
await this.addNodes([newNodeData]); await this.addNodes([newNodeData]);
const pinData = this.workflowsStore.pinDataByNodeName(nodeName); const pinData = this.workflowsStore.pinDataByNodeName(nodeName);
@ -3065,6 +3104,7 @@ export default mixins(
this.workflowsStore.resetAllNodesIssues(); this.workflowsStore.resetAllNodesIssues();
// vm.$forceUpdate(); // vm.$forceUpdate();
this.workflowsStore.$patch({ workflow: {} });
this.workflowsStore.setActive(false); this.workflowsStore.setActive(false);
this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.workflowsStore.setWorkflowName({ newName: '', setStateDirty: false }); this.workflowsStore.setWorkflowName({ newName: '', setStateDirty: false });
@ -3094,7 +3134,6 @@ export default mixins(
}, },
async loadCredentials(): Promise<void> { async loadCredentials(): Promise<void> {
await this.credentialsStore.fetchAllCredentials(); await this.credentialsStore.fetchAllCredentials();
await this.credentialsStore.fetchForeignCredentials();
}, },
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> { async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
const allNodes: INodeTypeDescription[] = this.nodeTypesStore.allNodeTypes; const allNodes: INodeTypeDescription[] = this.nodeTypesStore.allNodeTypes;
@ -3217,6 +3256,10 @@ export default mixins(
onAddNode({ nodeTypeName, position }: { nodeTypeName: string; position?: [number, number] }) { onAddNode({ nodeTypeName, position }: { nodeTypeName: string; position?: [number, number] }) {
this.addNode(nodeTypeName, { position }); this.addNode(nodeTypeName, { position });
}, },
async saveCurrentWorkflowExternal(callback: () => void) {
await this.saveCurrentWorkflow();
callback?.();
},
}, },
async mounted() { async mounted() {
this.$titleReset(); this.$titleReset();
@ -3304,8 +3347,6 @@ export default mixins(
}, promptTimeout); }, promptTimeout);
} }
} }
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
}, },
activated() { activated() {
const openSideMenu = this.uiStore.addFirstStepOnLoad; const openSideMenu = this.uiStore.addFirstStepOnLoad;
@ -3317,23 +3358,29 @@ export default mixins(
document.addEventListener('keydown', this.keyDown); document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp); document.addEventListener('keyup', this.keyUp);
window.addEventListener('message', this.onPostMessageReceived); window.addEventListener('message', this.onPostMessageReceived);
this.$root.$on('newWorkflow', this.newWorkflow); this.$root.$on('newWorkflow', this.newWorkflow);
this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent); this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent); this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
dataPinningEventBus.$on('pin-data', this.addPinDataConnections); dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections); dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
nodeViewEventBus.$on('saveWorkflow', this.saveCurrentWorkflowExternal);
this.canvasStore.isDemo = this.isDemo; this.canvasStore.isDemo = this.isDemo;
}, },
deactivated () { deactivated () {
document.removeEventListener('keydown', this.keyDown); document.removeEventListener('keydown', this.keyDown);
document.removeEventListener('keyup', this.keyUp); document.removeEventListener('keyup', this.keyUp);
window.removeEventListener('message', this.onPostMessageReceived); window.removeEventListener('message', this.onPostMessageReceived);
this.$root.$off('newWorkflow', this.newWorkflow); this.$root.$off('newWorkflow', this.newWorkflow);
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent); this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent); this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
dataPinningEventBus.$off('pin-data', this.addPinDataConnections); dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections); dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);
nodeViewEventBus.$off('saveWorkflow', this.saveCurrentWorkflowExternal);
}, },
destroyed() { destroyed() {
this.resetWorkspace(); this.resetWorkspace();
@ -3342,9 +3389,6 @@ export default mixins(
this.$root.$off('newWorkflow', this.newWorkflow); this.$root.$off('newWorkflow', this.newWorkflow);
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent); this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent); this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);
}, },
}); });
</script> </script>

View file

@ -7,7 +7,7 @@
:filters="filters" :filters="filters"
:additional-filters-handler="onFilter" :additional-filters-handler="onFilter"
:show-aside="allWorkflows.length > 0" :show-aside="allWorkflows.length > 0"
:shareable="false" :shareable="isShareable"
@click:add="addWorkflow" @click:add="addWorkflow"
@update:filters="filters = $event" @update:filters="filters = $event"
> >
@ -69,7 +69,7 @@ import PageViewLayoutList from "@/components/layouts/PageViewLayoutList.vue";
import WorkflowCard from "@/components/WorkflowCard.vue"; import WorkflowCard from "@/components/WorkflowCard.vue";
import TemplateCard from "@/components/TemplateCard.vue"; import TemplateCard from "@/components/TemplateCard.vue";
import { debounceHelper } from '@/components/mixins/debounce'; import { debounceHelper } from '@/components/mixins/debounce';
import {VIEWS} from '@/constants'; import {EnterpriseEditionFeature, VIEWS} from '@/constants';
import Vue from "vue"; import Vue from "vue";
import {ITag, IUser, IWorkflowDb} from "@/Interface"; import {ITag, IUser, IWorkflowDb} from "@/Interface";
import TagsDropdown from "@/components/TagsDropdown.vue"; import TagsDropdown from "@/components/TagsDropdown.vue";
@ -118,6 +118,9 @@ export default mixins(
allWorkflows(): IWorkflowDb[] { allWorkflows(): IWorkflowDb[] {
return this.workflowsStore.allWorkflows; return this.workflowsStore.allWorkflows;
}, },
isShareable(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing);
},
}, },
methods: { methods: {
addWorkflow() { addWorkflow() {
@ -188,8 +191,7 @@ export default mixins(
svg { svg {
width: 48px!important; width: 48px!important;
color: var(--color-foreground-dark); color: var(--color-foreground-dark);
transition: color 0.3s ease; transition: color 0.3s ease;}
}
} }
</style> </style>