mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 20:54:07 -08:00
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:
parent
d1ffc58aa4
commit
898c25fd7e
|
@ -892,6 +892,7 @@ export const schema = {
|
|||
sharing: {
|
||||
format: Boolean,
|
||||
default: false,
|
||||
env: 'N8N_SHARING_ENABLED',
|
||||
},
|
||||
},
|
||||
// This is a temporary flag (acting as feature toggle)
|
||||
|
@ -899,6 +900,7 @@ export const schema = {
|
|||
workflowSharingEnabled: {
|
||||
format: Boolean,
|
||||
default: false,
|
||||
env: 'N8N_WORKFLOW_SHARING_ENABLED',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -15,8 +15,13 @@
|
|||
>
|
||||
{{ t('nds.auth.roles.owner') }}
|
||||
</n8n-badge>
|
||||
<slot
|
||||
v-if="!user.isOwner && !readonly"
|
||||
name="actions"
|
||||
:user="user"
|
||||
/>
|
||||
<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"
|
||||
:actions="getActions(user)"
|
||||
theme="dark"
|
||||
|
@ -35,6 +40,7 @@ import N8nUserInfo from '../N8nUserInfo';
|
|||
import Locale from '../../mixins/locale';
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { t } from '../../locale';
|
||||
import {PropType} from "vue";
|
||||
|
||||
export interface IUserListAction {
|
||||
label: string;
|
||||
|
@ -71,6 +77,10 @@ export default mixins(Locale).extend({
|
|||
type: String,
|
||||
default: () => t('nds.usersList.reinviteUser'),
|
||||
},
|
||||
actions: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => ['delete', 'reinvite'],
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
sortedUsers(): IUser[] {
|
||||
|
@ -113,6 +123,7 @@ export default mixins(Locale).extend({
|
|||
},
|
||||
methods: {
|
||||
getActions(user: IUser): IUserListAction[] {
|
||||
const actions = [];
|
||||
const DELETE: IUserListAction = {
|
||||
label: this.deleteLabel as string,
|
||||
value: 'delete',
|
||||
|
@ -127,16 +138,17 @@ export default mixins(Locale).extend({
|
|||
return [];
|
||||
}
|
||||
|
||||
if (user.firstName) {
|
||||
return [
|
||||
DELETE,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
REINVITE,
|
||||
DELETE,
|
||||
];
|
||||
if (!user.firstName) {
|
||||
if (this.actions.includes('reinvite')) {
|
||||
actions.push(REINVITE);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.actions.includes('delete')) {
|
||||
actions.push(DELETE);
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
onUserAction(user: IUser, action: string): void {
|
||||
if (action === 'delete' || action === 'reinvite') {
|
||||
|
|
|
@ -37,6 +37,7 @@ import {
|
|||
NodeParameterValueType,
|
||||
} from 'n8n-workflow';
|
||||
import { FAKE_DOOR_FEATURES } from './constants';
|
||||
import {ICredentialsDb} from "n8n";
|
||||
|
||||
export * from 'n8n-design-system/src/types';
|
||||
|
||||
|
@ -320,6 +321,7 @@ export interface IWorkflowDb {
|
|||
sharedWith?: Array<Partial<IUser>>;
|
||||
ownedBy?: Partial<IUser>;
|
||||
hash: string;
|
||||
usedCredentials?: Array<Partial<ICredentialsDb>>;
|
||||
}
|
||||
|
||||
// Identical to cli.Interfaces.ts
|
||||
|
@ -332,6 +334,14 @@ export interface IWorkflowShortResponse {
|
|||
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
|
||||
|
||||
|
@ -346,12 +356,17 @@ export interface IShareCredentialsPayload {
|
|||
shareWithIds: string[];
|
||||
}
|
||||
|
||||
export interface IShareWorkflowsPayload {
|
||||
shareWithIds: string[];
|
||||
}
|
||||
|
||||
export interface ICredentialsResponse extends ICredentialsEncrypted {
|
||||
id: string;
|
||||
createdAt: number | string;
|
||||
updatedAt: number | string;
|
||||
sharedWith?: Array<Partial<IUser>>;
|
||||
ownedBy?: Partial<IUser>;
|
||||
currentUserHasAccess?: boolean;
|
||||
}
|
||||
|
||||
export interface ICredentialsBase {
|
||||
|
@ -987,7 +1002,6 @@ export interface ICredentialMap {
|
|||
export interface ICredentialsState {
|
||||
credentialTypes: ICredentialTypeMap;
|
||||
credentials: ICredentialMap;
|
||||
foreignCredentials?: ICredentialMap;
|
||||
}
|
||||
|
||||
export interface ITagsState {
|
||||
|
@ -1112,7 +1126,7 @@ export type IFakeDoor = {
|
|||
uiLocations: IFakeDoorLocation[],
|
||||
};
|
||||
|
||||
export type IFakeDoorLocation = 'settings' | 'credentialsModal';
|
||||
export type IFakeDoorLocation = 'settings' | 'credentialsModal' | 'workflowShareModal';
|
||||
|
||||
export type INodeFilterType = "Regular" | "Trigger" | "All";
|
||||
|
||||
|
|
|
@ -51,9 +51,3 @@ export async function oAuth2CredentialAuthorize(context: IRestApiContext, data:
|
|||
export async function testCredential(context: IRestApiContext, data: INodeCredentialTestRequest): Promise<INodeCredentialTestResult> {
|
||||
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 [];
|
||||
}
|
||||
|
|
13
packages/editor-ui/src/api/workflows.ee.ts
Normal file
13
packages/editor-ui/src/api/workflows.ee.ts
Normal 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);
|
||||
}
|
|
@ -73,7 +73,7 @@ export default mixins(
|
|||
},
|
||||
methods: {
|
||||
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));
|
||||
},
|
||||
async onRemoveSharee(userId: string) {
|
||||
|
|
|
@ -67,6 +67,15 @@
|
|||
<span class="activator">
|
||||
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" />
|
||||
</span>
|
||||
<enterprise-edition :features="[EnterpriseEditionFeature.WorkflowSharing]">
|
||||
<n8n-button
|
||||
type="tertiary"
|
||||
class="mr-2xs"
|
||||
@click="onShareButtonClick"
|
||||
>
|
||||
{{ $locale.baseText('workflowDetails.share') }}
|
||||
</n8n-button>
|
||||
</enterprise-edition>
|
||||
<SaveButton
|
||||
type="secondary"
|
||||
:saved="!this.isDirty && !this.isNewWorkflow"
|
||||
|
@ -87,10 +96,12 @@ import Vue from "vue";
|
|||
import mixins from "vue-typed-mixins";
|
||||
import {
|
||||
DUPLICATE_MODAL_KEY,
|
||||
EnterpriseEditionFeature,
|
||||
MAX_WORKFLOW_NAME_LENGTH,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
VIEWS, WORKFLOW_MENU_ACTIONS,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
WORKFLOW_SHARE_MODAL_KEY,
|
||||
} from "@/constants";
|
||||
|
||||
import ShortenName from "@/components/ShortenName.vue";
|
||||
|
@ -143,6 +154,7 @@ export default mixins(workflowHelpers, titleChange).extend({
|
|||
tagsEditBus: new Vue(),
|
||||
MAX_WORKFLOW_NAME_LENGTH,
|
||||
tagsSaving: false,
|
||||
EnterpriseEditionFeature,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -228,6 +240,9 @@ export default mixins(workflowHelpers, titleChange).extend({
|
|||
const saved = await this.saveCurrentWorkflow({ id: currentId, name: this.workflowName, tags: this.currentWorkflowTagIds });
|
||||
if (saved) await this.settingsStore.fetchPromptsData();
|
||||
},
|
||||
onShareButtonClick() {
|
||||
this.uiStore.openModal(WORKFLOW_SHARE_MODAL_KEY);
|
||||
},
|
||||
onTagsEditEnable() {
|
||||
this.$data.appliedTagIds = this.currentWorkflowTagIds;
|
||||
this.$data.isTagsEditEnabled = true;
|
||||
|
|
|
@ -82,6 +82,10 @@
|
|||
<ActivationModal />
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="WORKFLOW_SHARE_MODAL_KEY">
|
||||
<WorkflowShareModal />
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="ONBOARDING_CALL_SIGNUP_MODAL_KEY">
|
||||
<OnboardingCallSignupModal />
|
||||
</ModalRoot>
|
||||
|
@ -128,6 +132,7 @@ import {
|
|||
VERSIONS_MODAL_KEY,
|
||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
WORKFLOW_SHARE_MODAL_KEY,
|
||||
IMPORT_CURL_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
|
@ -151,6 +156,7 @@ import DeleteUserModal from "./DeleteUserModal.vue";
|
|||
import ExecutionsList from "./ExecutionsList.vue";
|
||||
import ActivationModal from "./ActivationModal.vue";
|
||||
import ImportCurlModal from './ImportCurlModal.vue';
|
||||
import WorkflowShareModal from './WorkflowShareModal.ee.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: "Modals",
|
||||
|
@ -174,6 +180,7 @@ export default Vue.extend({
|
|||
UpdatesPanel,
|
||||
ValueSurvey,
|
||||
WorkflowSettings,
|
||||
WorkflowShareModal,
|
||||
ImportCurlModal,
|
||||
},
|
||||
data: () => ({
|
||||
|
@ -192,6 +199,7 @@ export default Vue.extend({
|
|||
TAGS_MANAGER_MODAL_KEY,
|
||||
VERSIONS_MODAL_KEY,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
WORKFLOW_SHARE_MODAL_KEY,
|
||||
VALUE_SURVEY_MODAL_KEY,
|
||||
EXECUTIONS_MODAL_KEY,
|
||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
size="small"
|
||||
color="text-dark"
|
||||
>
|
||||
<div v-if="isReadOnly">
|
||||
<div v-if="readonly || isReadOnly">
|
||||
<n8n-input
|
||||
:value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name"
|
||||
disabled
|
||||
|
@ -94,6 +94,7 @@ export default mixins(
|
|||
).extend({
|
||||
name: 'NodeCredentials',
|
||||
props: [
|
||||
'readonly',
|
||||
'node', // INodeUi
|
||||
'overrideCredType', // cred type
|
||||
],
|
||||
|
|
|
@ -91,6 +91,7 @@
|
|||
:nodeType="activeNodeType"
|
||||
:isReadOnly="readOnly || hasForeignCredential"
|
||||
:blockUI="blockUi && showTriggerPanel"
|
||||
:executable="!readOnly || hasForeignCredential"
|
||||
@valueChanged="valueChanged"
|
||||
@execute="onNodeExecute"
|
||||
@stopExecution="onStopExecution"
|
||||
|
@ -136,6 +137,7 @@ import InputPanel from './InputPanel.vue';
|
|||
import TriggerPanel from './TriggerPanel.vue';
|
||||
import {
|
||||
BASE_NODE_SURVEY_URL,
|
||||
EnterpriseEditionFeature,
|
||||
START_NODE_TYPE,
|
||||
STICKY_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
|
@ -147,6 +149,7 @@ import { useWorkflowsStore } from '@/stores/workflows';
|
|||
import { useNDVStore } from '@/stores/ndv';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes';
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
import {useSettingsStore} from "@/stores/settings";
|
||||
|
||||
export default mixins(
|
||||
externalHooks,
|
||||
|
@ -188,7 +191,6 @@ export default mixins(
|
|||
pinDataDiscoveryTooltipVisible: false,
|
||||
avgInputRowHeight: 0,
|
||||
avgOutputRowHeight: 0,
|
||||
hasForeignCredential: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
|
@ -206,6 +208,7 @@ export default mixins(
|
|||
useNDVStore,
|
||||
useUIStore,
|
||||
useWorkflowsStore,
|
||||
useSettingsStore,
|
||||
),
|
||||
sessionId(): string {
|
||||
return this.ndvStore.sessionId;
|
||||
|
@ -365,6 +368,21 @@ export default mixins(
|
|||
blockUi(): boolean {
|
||||
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: {
|
||||
activeNode(node: INodeUi | null) {
|
||||
|
@ -384,8 +402,6 @@ export default mixins(
|
|||
nodeSubtitle: this.getNodeSubtitle(node, this.activeNodeType, this.getCurrentWorkflow()),
|
||||
});
|
||||
|
||||
this.checkForeignCredentials();
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.activeNode) {
|
||||
const outgoingConnections = this.workflowsStore.outgoingConnectionsByNodeName(
|
||||
|
@ -631,12 +647,6 @@ export default mixins(
|
|||
input_node_type: this.inputNode ? this.inputNode.type : '',
|
||||
});
|
||||
},
|
||||
checkForeignCredentials() {
|
||||
if(this.activeNode){
|
||||
const issues = this.getNodeCredentialIssues(this.activeNode);
|
||||
this.hasForeignCredential = !!issues?.credentials?.foreign;
|
||||
}
|
||||
},
|
||||
onStopExecution(){
|
||||
this.$emit('stopExecution');
|
||||
},
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
:isReadOnly="isReadOnly"
|
||||
@input="nameChanged"
|
||||
></NodeTitle>
|
||||
<div v-if="!isReadOnly">
|
||||
<div v-if="executable">
|
||||
<NodeExecuteButton
|
||||
v-if="!blockUI"
|
||||
:nodeName="node.name"
|
||||
|
@ -83,7 +83,7 @@
|
|||
@valueChanged="valueChanged"
|
||||
@activate="onWorkflowActivate"
|
||||
>
|
||||
<node-credentials :node="node" @credentialSelected="credentialSelected" />
|
||||
<node-credentials :node="node" :readonly="isReadOnly" @credentialSelected="credentialSelected" />
|
||||
</parameter-input-list>
|
||||
<div v-if="parametersNoneSetting.length === 0" class="no-parameters">
|
||||
<n8n-text>
|
||||
|
@ -264,6 +264,10 @@ export default mixins(externalHooks, nodeHelpers).extend({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
executable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
@ -26,9 +26,9 @@
|
|||
</div>
|
||||
<template #append>
|
||||
<div :class="$style.cardActions">
|
||||
<enterprise-edition :features="[EnterpriseEditionFeature.Sharing]" v-show="false">
|
||||
<enterprise-edition :features="[EnterpriseEditionFeature.WorkflowSharing]">
|
||||
<n8n-badge
|
||||
v-if="credentialPermissions.isOwner"
|
||||
v-if="workflowPermissions.isOwner"
|
||||
class="mr-xs"
|
||||
theme="tertiary"
|
||||
bold
|
||||
|
@ -122,7 +122,7 @@ export default mixins(
|
|||
currentUser (): IUser {
|
||||
return this.usersStore.currentUser || {} as IUser;
|
||||
},
|
||||
credentialPermissions(): IPermissions {
|
||||
workflowPermissions(): IPermissions {
|
||||
return getWorkflowPermissions(this.currentUser, this.data);
|
||||
},
|
||||
actions(): Array<{ label: string; value: string; }> {
|
||||
|
@ -135,7 +135,7 @@ export default mixins(
|
|||
label: this.$locale.baseText('workflows.item.duplicate'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
|
||||
},
|
||||
].concat(this.credentialPermissions.delete ? [{
|
||||
].concat(this.workflowPermissions.delete ? [{
|
||||
label: this.$locale.baseText('workflows.item.delete'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
|
||||
}]: []);
|
||||
|
|
265
packages/editor-ui/src/components/WorkflowShareModal.ee.vue
Normal file
265
packages/editor-ui/src/components/WorkflowShareModal.ee.vue
Normal 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>
|
|
@ -245,16 +245,6 @@ export const nodeHelpers = mixins(
|
|||
let credentialType: ICredentialType | null;
|
||||
let credentialDisplayName: string;
|
||||
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 {
|
||||
authentication,
|
||||
|
@ -350,9 +340,7 @@ export const nodeHelpers = mixins(
|
|||
}
|
||||
|
||||
if (nameMatches.length === 0) {
|
||||
if (this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) {
|
||||
foundIssues[credentialTypeDescription.name] = [this.$locale.baseText('nodeIssues.credentials.notAvailable')];
|
||||
} else {
|
||||
if (!this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing)) {
|
||||
foundIssues[credentialTypeDescription.name] = [this.$locale.baseText('nodeIssues.credentials.doNotExist', { interpolate: { name: selectedCredentials.name, type: credentialDisplayName } }), this.$locale.baseText('nodeIssues.credentials.doNotExist.hint')];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -383,9 +383,10 @@ export const pushConnection = mixins(
|
|||
// it can be displayed in the node-view
|
||||
this.updateNodesExecutionIssues();
|
||||
|
||||
const lastNodeExecuted: string | undefined = runDataExecuted.data.resultData.lastNodeExecuted;
|
||||
let itemsCount = 0;
|
||||
if(runDataExecuted.data.resultData.lastNodeExecuted && !runDataExecutedErrorMessage) {
|
||||
itemsCount = runDataExecuted.data.resultData.runData[runDataExecuted.data.resultData.lastNodeExecuted][0].data!.main[0]!.length;
|
||||
if(lastNodeExecuted && runDataExecuted.data.resultData.runData[lastNodeExecuted as string] && !runDataExecutedErrorMessage) {
|
||||
itemsCount = runDataExecuted.data.resultData.runData[lastNodeExecuted as string][0].data!.main[0]!.length;
|
||||
}
|
||||
|
||||
this.$externalHooks().run('pushConnection.executionFinished', {
|
||||
|
|
|
@ -3,7 +3,7 @@ import { ElNotificationComponent, ElNotificationOptions } from 'element-ui/types
|
|||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
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 { ElMessageComponent, ElMessageOptions, MessageType } from 'element-ui/types/message';
|
||||
import { sanitizeHtml } from '@/utils';
|
||||
|
@ -89,13 +89,13 @@ export const showMessage = mixins(externalHooks).extend({
|
|||
return this.$message(config);
|
||||
},
|
||||
|
||||
$getExecutionError(data: IRunExecutionData) {
|
||||
$getExecutionError(data: IRunExecutionData | IExecuteContextData) {
|
||||
const error = data.resultData.error;
|
||||
|
||||
let errorMessage: string;
|
||||
|
||||
if (data.resultData.lastNodeExecuted && error) {
|
||||
errorMessage = error.message;
|
||||
errorMessage = error.message || error.description;
|
||||
} else {
|
||||
errorMessage = 'There was a problem executing the workflow!';
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ export const DUPLICATE_MODAL_KEY = 'duplicate';
|
|||
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
|
||||
export const VERSIONS_MODAL_KEY = 'versions';
|
||||
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
|
||||
export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare';
|
||||
export const PERSONALIZATION_MODAL_KEY = 'personalization';
|
||||
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
|
||||
export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey';
|
||||
|
@ -315,7 +316,8 @@ export enum VIEWS {
|
|||
export enum FAKE_DOOR_FEATURES {
|
||||
ENVIRONMENTS = 'environments',
|
||||
LOGGING = 'logging',
|
||||
SHARING = 'sharing',
|
||||
CREDENTIALS_SHARING = 'credentialsSharing',
|
||||
WORKFLOWS_SHARING = 'workflowsSharing',
|
||||
}
|
||||
|
||||
export const ONBOARDING_PROMPT_TIMEBOX = 14;
|
||||
|
@ -370,6 +372,7 @@ export enum WORKFLOW_MENU_ACTIONS {
|
|||
*/
|
||||
export enum EnterpriseEditionFeature {
|
||||
Sharing = 'sharing',
|
||||
WorkflowSharing = 'workflowSharing',
|
||||
}
|
||||
export const MAIN_NODE_PANEL_WIDTH = 360;
|
||||
|
||||
|
@ -418,6 +421,7 @@ export enum STORES {
|
|||
UI = 'ui',
|
||||
USERS = 'users',
|
||||
WORKFLOWS = 'workflows',
|
||||
WORKFLOWS_EE = 'workflowsEE',
|
||||
NDV = 'ndv',
|
||||
TEMPLATES = 'templates',
|
||||
NODE_TYPES = 'nodeTypes',
|
||||
|
|
3
packages/editor-ui/src/event-bus/node-view-event-bus.ts
Normal file
3
packages/editor-ui/src/event-bus/node-view-event-bus.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export const nodeViewEventBus = new Vue();
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import {IUser, ICredentialsResponse, IRootState, IWorkflowDb} from "@/Interface";
|
||||
import {EnterpriseEditionFeature} from "@/constants";
|
||||
import {EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID} from "@/constants";
|
||||
import { useSettingsStore } from "./stores/settings";
|
||||
|
||||
export enum UserRole {
|
||||
|
@ -32,9 +32,9 @@ export type IPermissionsTable = IPermissionsTableRow[];
|
|||
* @param user
|
||||
* @param table
|
||||
*/
|
||||
export const parsePermissionsTable = (user: IUser, table: IPermissionsTable): IPermissions => {
|
||||
export const parsePermissionsTable = (user: IUser | null, table: IPermissionsTable): IPermissions => {
|
||||
const genericTable = [
|
||||
{ name: UserRole.InstanceOwner, test: () => user.isOwner },
|
||||
{ name: UserRole.InstanceOwner, test: () => user?.isOwner },
|
||||
];
|
||||
|
||||
return [
|
||||
|
@ -53,11 +53,11 @@ export const parsePermissionsTable = (user: IUser, table: IPermissionsTable): IP
|
|||
* User permissions definition
|
||||
*/
|
||||
|
||||
export const getCredentialPermissions = (user: IUser, credential: ICredentialsResponse) => {
|
||||
export const getCredentialPermissions = (user: IUser | null, credential: ICredentialsResponse) => {
|
||||
const settingsStore = useSettingsStore();
|
||||
const table: IPermissionsTable = [
|
||||
{ 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.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: 'read', test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceReader] },
|
||||
{ name: 'save', 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);
|
||||
};
|
||||
|
||||
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 = [
|
||||
// { name: UserRole.ResourceOwner, test: () => !!(workflow && workflow.ownedBy && workflow.ownedBy.id === user.id) || !useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing) },
|
||||
{ name: UserRole.ResourceOwner, test: () => true },
|
||||
// { name: UserRole.ResourceReader, test: () => !!(workflow && workflow.sharedWith && workflow.sharedWith.find((sharee) => sharee.id === user.id)) },
|
||||
{ name: UserRole.ResourceReader, test: () => true },
|
||||
{ name: UserRole.ResourceOwner, test: () => !!(isNewWorkflow || workflow && workflow.ownedBy && workflow.ownedBy.id === user?.id) || !settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing) },
|
||||
{ name: UserRole.ResourceReader, test: () => !!(workflow && workflow.sharedWith && workflow.sharedWith.find((sharee) => sharee.id === user?.id)) },
|
||||
{ name: 'read', test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceReader] },
|
||||
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
||||
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
||||
|
|
|
@ -486,6 +486,11 @@
|
|||
"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.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.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": "We’re working on environments (as a paid feature)",
|
||||
|
@ -1247,6 +1252,7 @@
|
|||
"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.thisWorkflowHasNoTriggerNodes": "This workflow has no trigger nodes that require activation",
|
||||
"workflowDetails.share": "Share",
|
||||
"workflowDetails.active": "Active",
|
||||
"workflowDetails.addTag": "Add tag",
|
||||
"workflowDetails.chooseOrCreateATag": "Choose or create a tag",
|
||||
|
@ -1361,6 +1367,18 @@
|
|||
"workflows.empty.description": "Create your first workflow",
|
||||
"workflows.empty.startFromScratch": "Start from scratch",
|
||||
"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.input.label": "cURL Command",
|
||||
"importCurlModal.input.placeholder": "Paste the cURL command here",
|
||||
|
|
|
@ -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 { getAppNameFromCredType } from "@/components/helpers";
|
||||
import { EnterpriseEditionFeature, STORES } from "@/constants";
|
||||
|
@ -33,10 +33,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
|||
return Object.values(this.credentials)
|
||||
.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[]} {
|
||||
const credentials = this.allCredentials;
|
||||
const types = this.allCredentialTypes;
|
||||
|
@ -53,6 +49,9 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
|||
getCredentialById() {
|
||||
return (id: string): ICredentialsResponse => this.credentials[id];
|
||||
},
|
||||
foreignCredentialsById(): ICredentialMap {
|
||||
return Object.fromEntries(Object.entries(this.credentials).filter(([_, credential]) => credential.hasOwnProperty('currentUserHasAccess')));
|
||||
},
|
||||
getCredentialByIdAndType() {
|
||||
return (id: string, type: string): ICredentialsResponse | undefined => {
|
||||
const credential = this.credentials[id];
|
||||
|
@ -138,13 +137,12 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
|||
return accu;
|
||||
}, {});
|
||||
},
|
||||
setForeignCredentials(credentials: ICredentialsResponse[]): void {
|
||||
this.foreignCredentials = credentials.reduce((accu: ICredentialMap, cred: ICredentialsResponse) => {
|
||||
addCredentials(credentials: ICredentialsResponse[]): void {
|
||||
credentials.forEach((cred: ICredentialsResponse) => {
|
||||
if (cred.id) {
|
||||
accu[cred.id] = cred;
|
||||
this.credentials[cred.id] = { ...this.credentials[cred.id], ...cred };
|
||||
}
|
||||
return accu;
|
||||
}, {});
|
||||
});
|
||||
},
|
||||
upsertCredential(credential: ICredentialsResponse): void {
|
||||
if (credential.id) {
|
||||
|
@ -168,12 +166,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
|||
this.setCredentials(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> {
|
||||
const rootStore = useRootStore();
|
||||
return await getCredentialData(rootStore.getRestApiContext, id);
|
||||
|
|
|
@ -26,7 +26,7 @@ import {
|
|||
VERSIONS_MODAL_KEY,
|
||||
VIEWS,
|
||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY,
|
||||
} from "@/constants";
|
||||
import {
|
||||
CurlToJSONResponse,
|
||||
|
@ -97,6 +97,9 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
[EXECUTIONS_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[WORKFLOW_SHARE_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
[WORKFLOW_ACTIVE_MODAL_KEY]: {
|
||||
open: false,
|
||||
},
|
||||
|
@ -141,13 +144,22 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
uiLocations: ['settings'],
|
||||
},
|
||||
{
|
||||
id: FAKE_DOOR_FEATURES.SHARING,
|
||||
id: FAKE_DOOR_FEATURES.CREDENTIALS_SHARING,
|
||||
featureName: 'fakeDoor.credentialEdit.sharing.name',
|
||||
actionBoxTitle: 'fakeDoor.credentialEdit.sharing.actionBox.title',
|
||||
actionBoxDescription: 'fakeDoor.credentialEdit.sharing.actionBox.description',
|
||||
linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=sharing',
|
||||
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: {
|
||||
isDragging: false,
|
||||
|
|
|
@ -23,8 +23,8 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
|||
currentUser(): IUser | null {
|
||||
return this.currentUserId ? this.users[this.currentUserId] : null;
|
||||
},
|
||||
getUserById(): (userId: string) => IUser | null {
|
||||
return (userId: string): IUser | null => this.users[userId];
|
||||
getUserById(state) {
|
||||
return (userId: string): IUser | null => state.users[userId];
|
||||
},
|
||||
globalRoleName(): string {
|
||||
return this.currentUser?.globalRole?.name || '';
|
||||
|
|
67
packages/editor-ui/src/stores/workflows.ee.ts
Normal file
67
packages/editor-ui/src/stores/workflows.ee.ts
Normal 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;
|
|
@ -149,7 +149,7 @@ import {
|
|||
STICKY_NODE_TYPE,
|
||||
VIEWS,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
TRIGGER_NODE_FILTER,
|
||||
TRIGGER_NODE_FILTER, EnterpriseEditionFeature,
|
||||
} from '@/constants';
|
||||
import { copyPaste } from '@/components/mixins/copyPaste';
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
|
@ -221,6 +221,7 @@ import { useSettingsStore } from '@/stores/settings';
|
|||
import { useUsersStore } from '@/stores/users';
|
||||
import { getNodeViewTab } from '@/components/helpers';
|
||||
import { Route, RawLocation } from 'vue-router';
|
||||
import { nodeViewEventBus } from '@/event-bus/node-view-event-bus';
|
||||
import { useWorkflowsStore } from '@/stores/workflows';
|
||||
import { useRootStore } from '@/stores/n8nRootStore';
|
||||
import { useNDVStore } from '@/stores/ndv';
|
||||
|
@ -231,6 +232,7 @@ import { useTagsStore } from '@/stores/tags';
|
|||
import { useNodeCreatorStore } from '@/stores/nodeCreator';
|
||||
import { dataPinningEventBus } from '@/event-bus/data-pinning-event-bus';
|
||||
import { useCanvasStore } from '@/stores/canvas';
|
||||
import useWorkflowsEEStore from "@/stores/workflows.ee";
|
||||
|
||||
interface AddNodeOptions {
|
||||
position?: XYPosition;
|
||||
|
@ -395,6 +397,7 @@ export default mixins(
|
|||
useUIStore,
|
||||
useUsersStore,
|
||||
useWorkflowsStore,
|
||||
useWorkflowsEEStore,
|
||||
),
|
||||
nativelyNumberSuffixedDefaults(): string[] {
|
||||
return this.rootStore.nativelyNumberSuffixedDefaults;
|
||||
|
@ -830,6 +833,7 @@ export default mixins(
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.workflowsStore.setActive(data.active || false);
|
||||
this.workflowsStore.setWorkflowId(workflowId);
|
||||
this.workflowsStore.setWorkflowName({ newName: data.name, setStateDirty: false });
|
||||
|
@ -837,6 +841,32 @@ export default mixins(
|
|||
this.workflowsStore.setWorkflowPinData(data.pinData || {});
|
||||
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 tagIds = tags.map((tag) => tag.id);
|
||||
this.workflowsStore.setWorkflowTagIds(tagIds || []);
|
||||
|
@ -2372,6 +2402,15 @@ export default mixins(
|
|||
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]);
|
||||
|
||||
const pinData = this.workflowsStore.pinDataByNodeName(nodeName);
|
||||
|
@ -3065,6 +3104,7 @@ export default mixins(
|
|||
this.workflowsStore.resetAllNodesIssues();
|
||||
// vm.$forceUpdate();
|
||||
|
||||
this.workflowsStore.$patch({ workflow: {} });
|
||||
this.workflowsStore.setActive(false);
|
||||
this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
|
||||
this.workflowsStore.setWorkflowName({ newName: '', setStateDirty: false });
|
||||
|
@ -3094,7 +3134,6 @@ export default mixins(
|
|||
},
|
||||
async loadCredentials(): Promise<void> {
|
||||
await this.credentialsStore.fetchAllCredentials();
|
||||
await this.credentialsStore.fetchForeignCredentials();
|
||||
},
|
||||
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
|
||||
const allNodes: INodeTypeDescription[] = this.nodeTypesStore.allNodeTypes;
|
||||
|
@ -3217,6 +3256,10 @@ export default mixins(
|
|||
onAddNode({ nodeTypeName, position }: { nodeTypeName: string; position?: [number, number] }) {
|
||||
this.addNode(nodeTypeName, { position });
|
||||
},
|
||||
async saveCurrentWorkflowExternal(callback: () => void) {
|
||||
await this.saveCurrentWorkflow();
|
||||
callback?.();
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.$titleReset();
|
||||
|
@ -3304,8 +3347,6 @@ export default mixins(
|
|||
}, promptTimeout);
|
||||
}
|
||||
}
|
||||
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
|
||||
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
|
||||
},
|
||||
activated() {
|
||||
const openSideMenu = this.uiStore.addFirstStepOnLoad;
|
||||
|
@ -3317,23 +3358,29 @@ export default mixins(
|
|||
document.addEventListener('keydown', this.keyDown);
|
||||
document.addEventListener('keyup', this.keyUp);
|
||||
window.addEventListener('message', this.onPostMessageReceived);
|
||||
|
||||
this.$root.$on('newWorkflow', this.newWorkflow);
|
||||
this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent);
|
||||
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
|
||||
|
||||
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
|
||||
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
|
||||
nodeViewEventBus.$on('saveWorkflow', this.saveCurrentWorkflowExternal);
|
||||
|
||||
this.canvasStore.isDemo = this.isDemo;
|
||||
},
|
||||
deactivated () {
|
||||
document.removeEventListener('keydown', this.keyDown);
|
||||
document.removeEventListener('keyup', this.keyUp);
|
||||
window.removeEventListener('message', this.onPostMessageReceived);
|
||||
|
||||
this.$root.$off('newWorkflow', this.newWorkflow);
|
||||
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
|
||||
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
|
||||
|
||||
dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
|
||||
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);
|
||||
nodeViewEventBus.$off('saveWorkflow', this.saveCurrentWorkflowExternal);
|
||||
},
|
||||
destroyed() {
|
||||
this.resetWorkspace();
|
||||
|
@ -3342,9 +3389,6 @@ export default mixins(
|
|||
this.$root.$off('newWorkflow', this.newWorkflow);
|
||||
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
|
||||
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
|
||||
|
||||
dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
|
||||
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
:filters="filters"
|
||||
:additional-filters-handler="onFilter"
|
||||
:show-aside="allWorkflows.length > 0"
|
||||
:shareable="false"
|
||||
:shareable="isShareable"
|
||||
@click:add="addWorkflow"
|
||||
@update:filters="filters = $event"
|
||||
>
|
||||
|
@ -69,7 +69,7 @@ import PageViewLayoutList from "@/components/layouts/PageViewLayoutList.vue";
|
|||
import WorkflowCard from "@/components/WorkflowCard.vue";
|
||||
import TemplateCard from "@/components/TemplateCard.vue";
|
||||
import { debounceHelper } from '@/components/mixins/debounce';
|
||||
import {VIEWS} from '@/constants';
|
||||
import {EnterpriseEditionFeature, VIEWS} from '@/constants';
|
||||
import Vue from "vue";
|
||||
import {ITag, IUser, IWorkflowDb} from "@/Interface";
|
||||
import TagsDropdown from "@/components/TagsDropdown.vue";
|
||||
|
@ -118,6 +118,9 @@ export default mixins(
|
|||
allWorkflows(): IWorkflowDb[] {
|
||||
return this.workflowsStore.allWorkflows;
|
||||
},
|
||||
isShareable(): boolean {
|
||||
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addWorkflow() {
|
||||
|
@ -188,8 +191,7 @@ export default mixins(
|
|||
svg {
|
||||
width: 48px!important;
|
||||
color: var(--color-foreground-dark);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
transition: color 0.3s ease;}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
Loading…
Reference in a new issue