refactor: Workflow sharing bug bash fixes (#4888)

* fix: Prevent workflows with only manual trigger from being activated

* fix: Fix workflow id when sharing from workflows list

* fix: Update sharing modal translations

* fix: Allow sharees to disable workflows and fix issue with unique key when removing a user

* refactor: Improve error messages and change logging level to be less verbose

* fix: Broken user removal transfer issue

* feat: Implement workflow sharing BE telemetry

* chore: temporarily add sharing env vars

* feat: Implement BE telemetry for workflow sharing

* fix: Prevent issues with possibly missing workflow id

* feat: Replace WorkflowSharing flag references (no-changelog) (#4918)

* ci: Block all external network calls in tests (no-changelog) (#4930)

* setup nock to prevent tests from making any external requests

* mock all calls to posthog sdk

* feat: Replace WorkflowSharing flag references (no-changelog)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <netroy@users.noreply.github.com>

* refactor: Remove temporary feature flag for workflow sharing

* refactor: add sharing_role to both manual and node executions

* refactor: Allow changing name, position and disabled of read only nodes

* feat: Overhaul dynamic translations for local and cloud (#4943)

* feat: Overhaul dynamic translations for local and cloud

* fix: remove type casting

* chore: remove unused translations

* fix: fix workflow sharing translation

* test: Fix broken test

* refactor: remove unnecessary import

* refactor: Minor code improvements

* refactor: rename dynamicTranslations to contextBasedTranslationKeys

* fix: fix type imports

* refactor: Consolidate sharing feature check

* feat: update cred sharing unavailable translations

* feat: update upgrade message when user management not available

* fix: rename plan names to Pro and Power

* feat: update translations to no longer contain plan names

* wip: subworkflow permissions

* feat: add workflowsFromSameOwner caller policy

* feat: Fix subworkflow permissions

* shared entites should check for role when deleting users

* refactor: remove circular dependency

* role filter shouldn't be an array

* fixed role issue

* fix: Corrected behavior when removing users

* feat: show instance owner credential sharing message only if isnt sharee

* feat: update workflow caller policy caller ids labels

* feat: update upgrade plan links to contain instance ids

* fix: show check errors below creds message only to owner

* fix(editor): Hide usage page on cloud

* fix: update credential validation error message for sharee

* fix(core): Remove duplicate import

* fix(editor): Extending deployment types

* feat: Overhaul contextual translations (#4992)

feat: update how contextual translations work

* refactor: improve messageing for subworkflow permissions

* test: Fix issue with user deletion and transfer

* fix: Explicitly throw error message so it can be displayed in UI

Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <netroy@users.noreply.github.com>
Co-authored-by: freyamade <freya@n8n.io>
Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
This commit is contained in:
Omar Ajoue 2022-12-21 16:42:07 +01:00 committed by GitHub
parent e225c3190e
commit 25e9f0817a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 597 additions and 344 deletions

View file

@ -458,7 +458,11 @@ export interface IN8nUISettings {
saveManualExecutions: boolean;
executionTimeout: number;
maxExecutionTimeout: number;
workflowCallerPolicyDefaultOption: 'any' | 'none' | 'workflowsFromAList';
workflowCallerPolicyDefaultOption:
| 'any'
| 'none'
| 'workflowsFromAList'
| 'workflowsFromSameOwner';
oauthCallbackUrls: {
oauth1: string;
oauth2: string;
@ -498,7 +502,6 @@ export interface IN8nUISettings {
};
enterprise: {
sharing: boolean;
workflowSharing: boolean;
};
hideUsagePage: boolean;
license: {

View file

@ -17,6 +17,7 @@ import {
IExecutionTrackProperties,
} from '@/Interfaces';
import { Telemetry } from '@/telemetry';
import { RoleService } from './role/role.service';
export class InternalHooksClass implements IInternalHooksClass {
private versionCli: string;
@ -111,6 +112,14 @@ export class InternalHooksClass implements IInternalHooksClass {
(note) => note.overlapping,
).length;
let userRole: 'owner' | 'sharee' | undefined = undefined;
if (userId && workflow.id) {
const role = await RoleService.getUserRoleForWorkflow(userId, workflow.id.toString());
if (role) {
userRole = role.name === 'owner' ? 'owner' : 'sharee';
}
}
return this.telemetry.track(
'User saved workflow',
{
@ -122,6 +131,7 @@ export class InternalHooksClass implements IInternalHooksClass {
version_cli: this.versionCli,
num_tags: workflow.tags?.length ?? 0,
public_api: publicApi,
sharing_role: userRole,
},
{ withPostHog: true },
);
@ -196,6 +206,14 @@ export class InternalHooksClass implements IInternalHooksClass {
nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
}
let userRole: 'owner' | 'sharee' | undefined = undefined;
if (userId) {
const role = await RoleService.getUserRoleForWorkflow(userId, workflow.id.toString());
if (role) {
userRole = role.name === 'owner' ? 'owner' : 'sharee';
}
}
const manualExecEventProperties: ITelemetryTrackProperties = {
user_id: userId,
workflow_id: workflow.id.toString(),
@ -205,6 +223,7 @@ export class InternalHooksClass implements IInternalHooksClass {
node_graph_string: properties.node_graph_string as string,
error_node_id: properties.error_node_id as string,
webhook_domain: null,
sharing_role: userRole,
};
if (!manualExecEventProperties.node_graph_string) {
@ -254,6 +273,16 @@ export class InternalHooksClass implements IInternalHooksClass {
]).then(() => {});
}
async onWorkflowSharingUpdate(workflowId: string, userId: string, userList: string[]) {
const properties: ITelemetryTrackProperties = {
workflow_id: workflowId,
user_id_sharer: userId,
user_id_list: userList,
};
return this.telemetry.track('User updated workflow sharing', properties, { withPostHog: true });
}
async onN8nStop(): Promise<void> {
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {

View file

@ -356,7 +356,6 @@ class App {
},
enterprise: {
sharing: false,
workflowSharing: false,
},
hideUsagePage: config.getEnv('hideUsagePage'),
license: {
@ -389,7 +388,6 @@ class App {
// refresh enterprise status
Object.assign(this.frontendSettings.enterprise, {
sharing: isSharingEnabled(),
workflowSharing: config.getEnv('enterprise.workflowSharingEnabled'),
});
if (config.get('nodes.packagesMissing').length > 0) {
@ -1003,7 +1001,7 @@ class App {
});
if (!shared) {
LoggerProxy.info('User attempted to access workflow errors without permissions', {
LoggerProxy.verbose('User attempted to access workflow errors without permissions', {
workflowId,
userId: req.user.id,
});

View file

@ -1,9 +1,17 @@
import { INode, NodeOperationError, Workflow } from 'n8n-workflow';
import {
INode,
NodeOperationError,
SubworkflowOperationError,
Workflow,
WorkflowOperationError,
} from 'n8n-workflow';
import { FindManyOptions, In, ObjectLiteral } from 'typeorm';
import * as Db from '@/Db';
import config from '@/config';
import type { SharedCredentials } from '@db/entities/SharedCredentials';
import { getRole } from './UserManagementHelper';
import { getRole, getWorkflowOwner, isSharingEnabled } from './UserManagementHelper';
import { WorkflowsService } from '@/workflows/workflows.services';
import { UserService } from '@/user/user.service';
export class PermissionChecker {
/**
@ -31,7 +39,7 @@ export class PermissionChecker {
let workflowUserIds = [userId];
if (workflow.id && config.getEnv('enterprise.workflowSharingEnabled')) {
if (workflow.id && isSharingEnabled()) {
const workflowSharings = await Db.collections.SharedWorkflow.find({
relations: ['workflow'],
where: { workflow: { id: Number(workflow.id) } },
@ -44,7 +52,7 @@ export class PermissionChecker {
where: { user: In(workflowUserIds) },
};
if (!config.getEnv('enterprise.features.sharing')) {
if (!isSharingEnabled()) {
// If credential sharing is not enabled, get only credentials owned by this user
credentialsWhereCondition.where.role = await getRole('credential', 'owner');
}
@ -68,6 +76,72 @@ export class PermissionChecker {
});
}
static async checkSubworkflowExecutePolicy(
subworkflow: Workflow,
userId: string,
parentWorkflowId?: string,
) {
/**
* Important considerations: both the current workflow and the parent can have empty IDs.
* This happens when a user is executing an unsaved workflow manually running a workflow
* loaded from a file or code, for instance.
* This is an important topic to keep in mind for all security checks
*/
if (!subworkflow.id) {
// It's a workflow from code and not loaded from DB
// No checks are necessary since it doesn't have any sort of settings
return;
}
let policy =
subworkflow.settings?.callerPolicy ?? config.getEnv('workflows.callerPolicyDefaultOption');
if (!isSharingEnabled()) {
// Community version allows only same owner workflows
policy = 'workflowsFromSameOwner';
}
const subworkflowOwner = await getWorkflowOwner(subworkflow.id);
const errorToThrow = new SubworkflowOperationError(
`Target workflow ID ${subworkflow.id ?? ''} may not be called`,
subworkflowOwner.id === userId
? 'Change the settings of the sub-workflow so it can be called by this one.'
: `${subworkflowOwner.firstName} (${subworkflowOwner.email}) can make this change. You may need to tell them the ID of this workflow, which is ${subworkflow.id}`,
);
if (policy === 'none') {
throw errorToThrow;
}
if (policy === 'workflowsFromAList') {
if (parentWorkflowId === undefined) {
throw errorToThrow;
}
const allowedCallerIds = (subworkflow.settings.callerIds as string | undefined)
?.split(',')
.map((id) => id.trim())
.filter((id) => id !== '');
if (!allowedCallerIds?.includes(parentWorkflowId)) {
throw errorToThrow;
}
}
if (policy === 'workflowsFromSameOwner') {
const user = await UserService.get({ id: userId });
if (!user) {
throw new WorkflowOperationError(
'Fatal error: user not found. Please contact the system administrator.',
);
}
const sharing = await WorkflowsService.getSharing(user, subworkflow.id, ['role', 'user']);
if (!sharing || sharing.role.name !== 'owner') {
throw errorToThrow;
}
}
}
private static mapCredIdsToNodes(workflow: Workflow) {
return Object.values(workflow.nodes).reduce<{ [credentialId: string]: INode[] }>(
(map, node) => {

View file

@ -15,10 +15,13 @@ import config from '@/config';
import { getWebhookBaseUrl } from '../WebhookHelpers';
import { getLicense } from '@/License';
import { WhereClause } from '@/Interfaces';
import { RoleService } from '@/role/role.service';
export async function getWorkflowOwner(workflowId: string | number): Promise<User> {
const workflowOwnerRole = await RoleService.get({ name: 'owner', scope: 'workflow' });
const sharedWorkflow = await Db.collections.SharedWorkflow.findOneOrFail({
where: { workflow: { id: workflowId } },
where: { workflow: { id: workflowId }, role: workflowOwnerRole },
relations: ['user', 'user.globalRole'],
});

View file

@ -25,6 +25,7 @@ import {
import config from '@/config';
import { issueCookie } from '../auth/jwt';
import { InternalHooksManager } from '@/InternalHooksManager';
import { RoleService } from '@/role/role.service';
export function usersNamespace(this: N8nApp): void {
/**
@ -403,33 +404,94 @@ export function usersNamespace(this: N8nApp): void {
const userToDelete = users.find((user) => user.id === req.params.id) as User;
const telemetryData: ITelemetryUserDeletionData = {
user_id: req.user.id,
target_user_old_status: userToDelete.isPending ? 'invited' : 'active',
target_user_id: idToDelete,
};
telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data';
if (transferId) {
telemetryData.migration_user_id = transferId;
}
const [workflowOwnerRole, credentialOwnerRole] = await Promise.all([
RoleService.get({ name: 'owner', scope: 'workflow' }),
RoleService.get({ name: 'owner', scope: 'credential' }),
]);
if (transferId) {
const transferee = users.find((user) => user.id === transferId);
await Db.transaction(async (transactionManager) => {
// Get all workflow ids belonging to user to delete
const sharedWorkflows = await transactionManager.getRepository(SharedWorkflow).find({
where: { user: userToDelete, role: workflowOwnerRole },
});
const sharedWorkflowIds = sharedWorkflows.map((sharedWorkflow) =>
sharedWorkflow.workflowId.toString(),
);
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await transactionManager.delete(SharedWorkflow, {
user: transferee,
workflow: In(sharedWorkflowIds.map((sharedWorkflowId) => ({ id: sharedWorkflowId }))),
});
// Transfer ownership of owned workflows
await transactionManager.update(
SharedWorkflow,
{ user: userToDelete },
{ user: userToDelete, role: workflowOwnerRole },
{ user: transferee },
);
// Now do the same for creds
// Get all workflow ids belonging to user to delete
const sharedCredentials = await transactionManager.getRepository(SharedCredentials).find({
where: { user: userToDelete, role: credentialOwnerRole },
});
const sharedCredentialIds = sharedCredentials.map((sharedCredential) =>
sharedCredential.credentialId.toString(),
);
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await transactionManager.delete(SharedCredentials, {
user: transferee,
credentials: In(
sharedCredentialIds.map((sharedCredentialId) => ({ id: sharedCredentialId })),
),
});
// Transfer ownership of owned credentials
await transactionManager.update(
SharedCredentials,
{ user: userToDelete },
{ user: userToDelete, role: credentialOwnerRole },
{ user: transferee },
);
// This will remove all shared workflows and credentials not owned
await transactionManager.delete(User, { id: userToDelete.id });
});
void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData, false);
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
return { success: true };
}
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
Db.collections.SharedWorkflow.find({
relations: ['workflow'],
where: { user: userToDelete },
where: { user: userToDelete, role: workflowOwnerRole },
}),
Db.collections.SharedCredentials.find({
relations: ['credentials'],
where: { user: userToDelete },
where: { user: userToDelete, role: credentialOwnerRole },
}),
]);
@ -450,22 +512,8 @@ export function usersNamespace(this: N8nApp): void {
await transactionManager.delete(User, { id: userToDelete.id });
});
const telemetryData: ITelemetryUserDeletionData = {
user_id: req.user.id,
target_user_old_status: userToDelete.isPending ? 'invited' : 'active',
target_user_id: idToDelete,
};
telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data';
if (transferId) {
telemetryData.migration_user_id = transferId;
}
void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData, false);
await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]);
return { success: true };
}),
);

View file

@ -65,6 +65,7 @@ import * as WorkflowHelpers from '@/WorkflowHelpers';
import { getUserById, getWorkflowOwner, whereClause } from '@/UserManagement/UserManagementHelper';
import { findSubworkflowStart } from '@/utils';
import { PermissionChecker } from './UserManagement/PermissionChecker';
import { WorkflowsService } from './workflows/workflows.services';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -779,34 +780,6 @@ export async function getRunData(
): Promise<IWorkflowExecutionDataProcess> {
const mode = 'integrated';
const policy =
workflowData.settings?.callerPolicy ?? config.getEnv('workflows.callerPolicyDefaultOption');
if (policy === 'none') {
throw new SubworkflowOperationError(
`Target workflow ID ${workflowData.id} may not be called by other workflows.`,
'Please update the settings of the target workflow or ask its owner to do so.',
);
}
if (
policy === 'workflowsFromAList' &&
typeof workflowData.settings?.callerIds === 'string' &&
parentWorkflowId !== undefined
) {
const allowedCallerIds = workflowData.settings.callerIds
.split(',')
.map((id) => id.trim())
.filter((id) => id !== '');
if (!allowedCallerIds.includes(parentWorkflowId)) {
throw new SubworkflowOperationError(
`Target workflow ID ${workflowData.id} may only be called by a list of workflows, which does not include current workflow ID ${parentWorkflowId}.`,
'Please update the settings of the target workflow or ask its owner to do so.',
);
}
}
const startingNode = findSubworkflowStart(workflowData.nodes);
// Always start with empty data if no inputData got supplied
@ -852,7 +825,6 @@ export async function getRunData(
export async function getWorkflowData(
workflowInfo: IExecuteWorkflowInfo,
userId: string,
parentWorkflowId?: string,
parentWorkflowSettings?: IWorkflowSettings,
): Promise<IWorkflowBase> {
@ -869,23 +841,15 @@ export async function getWorkflowData(
// to get initialized first
await Db.init();
}
const user = await getUserById(userId);
let relations = ['workflow', 'workflow.tags'];
if (config.getEnv('workflowTagsDisabled')) {
relations = relations.filter((relation) => relation !== 'workflow.tags');
}
const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags'];
const shared = await Db.collections.SharedWorkflow.findOne({
workflowData = await WorkflowsService.get(
{ id: parseInt(workflowInfo.id, 10) },
{
relations,
where: whereClause({
user,
entityType: 'workflow',
entityId: workflowInfo.id,
}),
});
workflowData = shared?.workflow;
},
);
if (workflowData === undefined) {
throw new Error(`The workflow with the id "${workflowInfo.id}" does not exist.`);
@ -911,7 +875,7 @@ export async function getWorkflowData(
async function executeWorkflow(
workflowInfo: IExecuteWorkflowInfo,
additionalData: IWorkflowExecuteAdditionalData,
options?: {
options: {
parentWorkflowId?: string;
inputData?: INodeExecutionData[];
parentExecutionId?: string;
@ -926,13 +890,8 @@ async function executeWorkflow(
const nodeTypes = NodeTypes();
const workflowData =
options?.loadedWorkflowData ??
(await getWorkflowData(
workflowInfo,
additionalData.userId,
options?.parentWorkflowId,
options?.parentWorkflowSettings,
));
options.loadedWorkflowData ??
(await getWorkflowData(workflowInfo, options.parentWorkflowId, options.parentWorkflowSettings));
const workflowName = workflowData ? workflowData.name : undefined;
const workflow = new Workflow({
@ -947,23 +906,28 @@ async function executeWorkflow(
});
const runData =
options?.loadedRunData ??
(await getRunData(workflowData, additionalData.userId, options?.inputData));
options.loadedRunData ??
(await getRunData(workflowData, additionalData.userId, options.inputData));
let executionId;
if (options?.parentExecutionId !== undefined) {
executionId = options?.parentExecutionId;
if (options.parentExecutionId !== undefined) {
executionId = options.parentExecutionId;
} else {
executionId =
options?.parentExecutionId !== undefined
? options?.parentExecutionId
options.parentExecutionId !== undefined
? options.parentExecutionId
: await ActiveExecutions.getInstance().add(runData);
}
let data;
try {
await PermissionChecker.check(workflow, additionalData.userId);
await PermissionChecker.checkSubworkflowExecutePolicy(
workflow,
additionalData.userId,
options.parentWorkflowId,
);
// Create new additionalData to have different workflow loaded and to call
// different webhooks
@ -1005,7 +969,7 @@ async function executeWorkflow(
runData.executionMode,
runExecutionData,
);
if (options?.parentExecutionId !== undefined) {
if (options.parentExecutionId !== undefined) {
// Must be changed to become typed
return {
startedAt: new Date(),
@ -1049,6 +1013,7 @@ async function executeWorkflow(
throw {
...error,
stack: error.stack,
message: error.message,
};
}

View file

@ -24,6 +24,7 @@ import config from '@/config';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { User } from '@db/entities/User';
import { getWorkflowOwner, whereClause } from '@/UserManagement/UserManagementHelper';
import omit from 'lodash.omit';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -558,15 +559,16 @@ export function validateWorkflowCredentialUsage(
nodesWithCredentialsUserDoesNotHaveAccessTo.forEach((node) => {
if (isTamperingAttempt(node.id)) {
Logger.info('Blocked workflow update due to tampering attempt', {
Logger.verbose('Blocked workflow update due to tampering attempt', {
nodeType: node.type,
nodeName: node.name,
nodeId: node.id,
nodeCredentials: node.credentials,
});
// Node is new, so this is probably a tampering attempt. Throw an error
throw new Error(
'Workflow contains new nodes with credentials the user does not have access to',
throw new NodeOperationError(
node,
`You don't have access to the credentials in the '${node.name}' node. Ask the owner to share them with you.`,
);
}
// Replace the node with the previous version of the node
@ -580,9 +582,14 @@ export function validateWorkflowCredentialUsage(
nodeName: node.name,
nodeId: node.id,
});
newWorkflowVersion.nodes[nodeIdx] = previousWorkflowVersion.nodes.find(
const previousNodeVersion = previousWorkflowVersion.nodes.find(
(previousNode) => previousNode.id === node.id,
)!;
);
// Allow changing only name, position and disabled status for read-only nodes
Object.assign(
newWorkflowVersion.nodes[nodeIdx],
omit(previousNodeVersion, ['name', 'position', 'disabled']),
);
});
return newWorkflowVersion;

View file

@ -48,6 +48,7 @@ import { InternalHooksManager } from '@/InternalHooksManager';
import { generateFailedExecutionFromError } from '@/WorkflowHelpers';
import { initErrorHandling } from '@/ErrorReporting';
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import { getLicense } from './License';
class WorkflowRunnerProcess {
data: IWorkflowExecutionDataProcessWithExecution | undefined;
@ -118,48 +119,11 @@ class WorkflowRunnerProcess {
const binaryDataConfig = config.getEnv('binaryDataManager');
await BinaryDataManager.init(binaryDataConfig);
// Credentials should now be loaded from database.
// We check if any node uses credentials. If it does, then
// init database.
let shouldInitializeDb = false;
// eslint-disable-next-line array-callback-return
inputData.workflowData.nodes.map((node) => {
if (Object.keys(node.credentials === undefined ? {} : node.credentials).length > 0) {
shouldInitializeDb = true;
}
if (node.type === 'n8n-nodes-base.executeWorkflow') {
// With UM, child workflows from arbitrary JSON
// Should be persisted by the child process,
// so DB needs to be initialized
shouldInitializeDb = true;
}
});
// Init db since we need to read the license.
await Db.init();
// This code has been split into 4 ifs just to make it easier to understand
// Can be made smaller but in the end it will make it impossible to read.
if (shouldInitializeDb) {
// initialize db as we need to load credentials
await Db.init();
} else if (
inputData.workflowData.settings !== undefined &&
inputData.workflowData.settings.saveExecutionProgress === true
) {
// Workflow settings specifying it should save
await Db.init();
} else if (
inputData.workflowData.settings !== undefined &&
inputData.workflowData.settings.saveExecutionProgress !== false &&
config.getEnv('executions.saveExecutionProgress')
) {
// Workflow settings not saying anything about saving but default settings says so
await Db.init();
} else if (
inputData.workflowData.settings === undefined &&
config.getEnv('executions.saveExecutionProgress')
) {
// Workflow settings not saying anything about saving but default settings says so
await Db.init();
}
const license = getLicense();
await license.init(instanceId, cli);
// Start timeout for the execution
let workflowTimeout = config.getEnv('executions.timeout'); // initialize with default
@ -245,7 +209,6 @@ class WorkflowRunnerProcess {
): Promise<Array<INodeExecutionData[] | null> | IRun> => {
const workflowData = await WorkflowExecuteAdditionalData.getWorkflowData(
workflowInfo,
userId,
options?.parentWorkflowId,
options?.parentWorkflowSettings,
);

View file

@ -28,7 +28,7 @@ async function checkWorkflowId(workflowId: string, user: User): Promise<boolean>
});
if (!shared) {
LoggerProxy.info('User attempted to read a workflow without permissions', {
LoggerProxy.verbose('User attempted to read a workflow without permissions', {
workflowId,
userId: user.id,
});

View file

@ -27,7 +27,6 @@ const config = convict(schema);
if (inE2ETests) {
config.set('enterprise.features.sharing', true);
config.set('enterprise.workflowSharingEnabled', true);
}
config.getEnv = config.get;

View file

@ -217,8 +217,8 @@ export const schema = {
},
callerPolicyDefaultOption: {
doc: 'Default option for which workflows may call the current workflow',
format: ['any', 'none', 'workflowsFromAList'] as const,
default: 'any',
format: ['any', 'none', 'workflowsFromAList', 'workflowsFromSameOwner'] as const,
default: 'workflowsFromSameOwner',
env: 'N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION',
},
},
@ -885,12 +885,6 @@ export const schema = {
default: false,
},
},
// This is a temporary flag (acting as feature toggle)
// Will be removed when feature goes live
workflowSharingEnabled: {
format: Boolean,
default: false,
},
},
hiringBanner: {

View file

@ -1,5 +1,4 @@
import express from 'express';
import config from '@/config';
import {
IExecutionFlattedResponse,
IExecutionResponse,
@ -14,7 +13,7 @@ import { EEExecutionsService } from './executions.service.ee';
export const EEExecutionsController = express.Router();
EEExecutionsController.use((req, res, next) => {
if (!isSharingEnabled() || !config.getEnv('enterprise.workflowSharingEnabled')) {
if (!isSharingEnabled()) {
// skip ee router and use free one
next('router');
return;

View file

@ -10,4 +10,15 @@ export class RoleService {
static async trxGet(transaction: EntityManager, role: Partial<Role>) {
return transaction.findOne(Role, role);
}
static async getUserRoleForWorkflow(userId: string, workflowId: string) {
const shared = await Db.collections.SharedWorkflow.findOne({
where: {
workflow: { id: workflowId },
user: { id: userId },
},
relations: ['role'],
});
return shared?.role;
}
}

View file

@ -4,7 +4,9 @@ import { User } from '@db/entities/User';
export class UserService {
static async get(user: Partial<User>): Promise<User | undefined> {
return Db.collections.User.findOne(user);
return Db.collections.User.findOne(user, {
relations: ['globalRole'],
});
}
static async getByIds(transaction: EntityManager, ids: string[]) {

View file

@ -22,7 +22,7 @@ import * as GenericHelpers from '@/GenericHelpers';
export const EEWorkflowController = express.Router();
EEWorkflowController.use((req, res, next) => {
if (!isSharingEnabled() || !config.getEnv('enterprise.workflowSharingEnabled')) {
if (!isSharingEnabled()) {
// skip ee router and use free one
next('router');
return;
@ -73,6 +73,12 @@ EEWorkflowController.put(
await EEWorkflows.share(trx, workflow, newShareeIds);
}
});
void InternalHooksManager.getInstance().onWorkflowSharingUpdate(
workflowId,
req.user.id,
shareWithIds,
);
}),
);
@ -94,7 +100,7 @@ EEWorkflowController.get(
if (!userSharing && req.user.globalRole.name !== 'owner') {
throw new ResponseHelper.UnauthorizedError(
'It looks like you cannot access this workflow. Ask the owner to share it with you.',
'You do not have permission to access this workflow. Ask the owner to share it with you',
);
}

View file

@ -213,7 +213,7 @@ workflowsController.get(
});
if (!shared) {
LoggerProxy.info('User attempted to access a workflow without permissions', {
LoggerProxy.verbose('User attempted to access a workflow without permissions', {
workflowId,
userId: req.user.id,
});
@ -286,7 +286,7 @@ workflowsController.delete(
});
if (!shared) {
LoggerProxy.info('User attempted to delete a workflow without permissions', {
LoggerProxy.verbose('User attempted to delete a workflow without permissions', {
workflowId,
userId: req.user.id,
});

View file

@ -15,6 +15,7 @@ import type {
} from './workflows.types';
import { EECredentialsService as EECredentials } from '@/credentials/credentials.service.ee';
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
import { NodeOperationError } from 'n8n-workflow';
export class EEWorkflowsService extends WorkflowsService {
static async getWorkflowIdsForUser(user: User) {
@ -189,6 +190,9 @@ export class EEWorkflowsService extends WorkflowsService {
allCredentials,
);
} catch (error) {
if (error instanceof NodeOperationError) {
throw new ResponseHelper.BadRequestError(error.message);
}
throw new ResponseHelper.BadRequestError(
'Invalid workflow credentials - make sure you have access to all credentials and try again.',
);

View file

@ -22,7 +22,7 @@ import { WorkflowRunner } from '@/WorkflowRunner';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import * as TestWebhooks from '@/TestWebhooks';
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
import { whereClause } from '@/UserManagement/UserManagementHelper';
import { isSharingEnabled, whereClause } from '@/UserManagement/UserManagementHelper';
export interface IGetWorkflowsQueryFilter {
id?: number | string;
@ -158,20 +158,26 @@ export class WorkflowsService {
return [];
}
const fields: Array<keyof WorkflowEntity> = ['id', 'name', 'active', 'createdAt', 'updatedAt'];
const fields: Array<keyof WorkflowEntity> = [
'id',
'name',
'active',
'createdAt',
'updatedAt',
'nodes',
];
const relations: string[] = [];
if (!config.getEnv('workflowTagsDisabled')) {
relations.push('tags');
}
const isSharingEnabled = config.getEnv('enterprise.features.sharing');
if (isSharingEnabled) {
if (isSharingEnabled()) {
relations.push('shared', 'shared.user', 'shared.role');
}
const query: FindManyOptions<WorkflowEntity> = {
select: isSharingEnabled ? [...fields, 'nodes', 'versionId'] : fields,
select: isSharingEnabled() ? [...fields, 'versionId'] : fields,
relations,
where: {
id: In(sharedWorkflowIds),
@ -210,7 +216,7 @@ export class WorkflowsService {
});
if (!shared) {
LoggerProxy.info('User attempted to update a workflow without permissions', {
LoggerProxy.verbose('User attempted to update a workflow without permissions', {
workflowId,
userId: user.id,
});
@ -351,7 +357,7 @@ export class WorkflowsService {
updatedWorkflow.active = false;
// Now return the original error for UI to display
throw error;
throw new ResponseHelper.BadRequestError((error as Error).message);
}
}

View file

@ -161,13 +161,13 @@ test('DELETE /users/:id should delete the user', async () => {
const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
relations: ['user'],
where: { user: userToDelete },
where: { user: userToDelete, role: workflowOwnerRole },
});
expect(sharedWorkflow).toBeUndefined(); // deleted
const sharedCredential = await Db.collections.SharedCredentials.findOne({
relations: ['user'],
where: { user: userToDelete },
where: { user: userToDelete, role: credentialOwnerRole },
});
expect(sharedCredential).toBeUndefined(); // deleted

View file

@ -47,8 +47,6 @@ beforeAll(async () => {
isSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
config.set('enterprise.workflowSharingEnabled', true); // @TODO: Remove once temp flag is removed
await utils.initNodeTypes();
workflowRunner = await utils.initActiveWorkflowRunner();
@ -666,7 +664,7 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () =>
expect(response.statusCode).toBe(400);
});
it('Should succeed but prevent modifying nodes that are read-only for the requester', async () => {
it('Should succeed but prevent modifying node attributes other than position, name and disabled', async () => {
const member1 = await testDb.createUser({ globalRole: globalMemberRole });
const member2 = await testDb.createUser({ globalRole: globalMemberRole });
@ -676,7 +674,9 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () =>
{
id: 'uuid-1234',
name: 'Start',
parameters: {},
parameters: {
firstParam: 123,
},
position: [-20, 260],
type: 'n8n-nodes-base.start',
typeVersion: 1,
@ -693,8 +693,10 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () =>
{
id: 'uuid-1234',
name: 'End',
parameters: {},
position: [-20, 260],
parameters: {
firstParam: 456,
},
position: [-20, 555],
type: 'n8n-nodes-base.no-op',
typeVersion: 1,
credentials: {
@ -703,6 +705,27 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () =>
name: 'fake credential',
},
},
disabled: true,
},
];
const expectedNodes: INode[] = [
{
id: 'uuid-1234',
name: 'End',
parameters: {
firstParam: 123,
},
position: [-20, 555],
type: 'n8n-nodes-base.start',
typeVersion: 1,
credentials: {
default: {
id: savedCredential.id.toString(),
name: savedCredential.name,
},
},
disabled: true,
},
];
@ -726,7 +749,7 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () =>
});
expect(response.statusCode).toBe(200);
expect(response.body.data.nodes).toMatchObject(originalNodes);
expect(response.body.data.nodes).toMatchObject(expectedNodes);
});
});

View file

@ -804,7 +804,7 @@ export interface IN8nUISettings {
};
enterprise: Record<string, boolean>;
deployment?: {
type: string;
type: string | 'default' | 'n8n-internal' | 'cloud' | 'desktop_mac' | 'desktop_win';
};
hideUsagePage: boolean;
license: {
@ -1079,10 +1079,6 @@ export interface IModalState {
httpNodeParameters?: string;
}
export interface NestedRecord<T> {
[key: string]: T | NestedRecord<T>;
}
export type IRunDataDisplayMode = 'table' | 'json' | 'binary' | 'schema';
export type NodePanelType = 'input' | 'output';
@ -1155,7 +1151,6 @@ export interface UIState {
currentView: string;
mainPanelPosition: number;
fakeDoorFeatures: IFakeDoor[];
dynamicTranslations: NestedRecord<string>;
draggable: {
isDragging: boolean;
type: string;

View file

@ -3,7 +3,14 @@
<banner
v-show="showValidationWarning"
theme="danger"
:message="$locale.baseText('credentialEdit.credentialConfig.pleaseCheckTheErrorsBelow')"
:message="
$locale.baseText(
`credentialEdit.credentialConfig.pleaseCheckTheErrorsBelow${
credentialPermissions.isOwner ? '' : '.sharee'
}`,
{ interpolate: { owner: credentialOwnerName } },
)
"
/>
<banner
@ -12,7 +19,7 @@
:message="
$locale.baseText(
`credentialEdit.credentialConfig.couldntConnectWithTheseSettings${
!credentialPermissions.isOwner ? '.sharee' : ''
credentialPermissions.isOwner ? '' : '.sharee'
}`,
{ interpolate: { owner: credentialOwnerName } },
)

View file

@ -77,11 +77,7 @@
@scrollToTop="scrollToTop"
/>
</div>
<enterprise-edition
v-else-if="activeTab === 'sharing' && credentialType"
:class="$style.mainContent"
:features="[EnterpriseEditionFeature.Sharing]"
>
<div v-else-if="activeTab === 'sharing' && credentialType" :class="$style.mainContent">
<CredentialSharing
:credential="currentCredential"
:credentialData="credentialData"
@ -90,7 +86,7 @@
:modalBus="modalBus"
@change="onChangeSharedWith"
/>
</enterprise-edition>
</div>
<div v-else-if="activeTab === 'details' && credentialType" :class="$style.mainContent">
<CredentialInfo
:nodeAccess="nodeAccess"
@ -111,7 +107,7 @@
<script lang="ts">
import Vue from 'vue';
import { ICredentialsResponse, IFakeDoor, IUser } from '@/Interface';
import type { ICredentialsResponse, IUser } from '@/Interface';
import {
CredentialInformation,
@ -391,9 +387,6 @@ export default mixins(showMessage, nodeHelpers).extend({
}
return true;
},
credentialsFakeDoorFeatures(): IFakeDoor[] {
return this.uiStore.getFakeDoorByLocation('credentialsModal');
},
credentialPermissions(): IPermissions {
if (this.loading) {
return {};
@ -405,7 +398,7 @@ export default mixins(showMessage, nodeHelpers).extend({
);
},
sidebarItems(): IMenuItem[] {
const items: IMenuItem[] = [
return [
{
id: 'connection',
label: this.$locale.baseText('credentialEdit.credentialEdit.connection'),
@ -415,26 +408,13 @@ export default mixins(showMessage, nodeHelpers).extend({
id: 'sharing',
label: this.$locale.baseText('credentialEdit.credentialEdit.sharing'),
position: 'top',
available: this.credentialType !== null && this.isSharingAvailable,
},
];
if (this.credentialType !== null && !this.isSharingAvailable) {
for (const item of this.credentialsFakeDoorFeatures) {
items.push({
id: `coming-soon/${item.id}`,
label: this.$locale.baseText(item.featureName as BaseTextKey),
position: 'top',
});
}
}
items.push({
{
id: 'details',
label: this.$locale.baseText('credentialEdit.credentialEdit.details'),
position: 'top',
});
return items;
},
];
},
isSharingAvailable(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);

View file

@ -1,7 +1,22 @@
<template>
<div :class="$style.container">
<div v-if="isDefaultUser">
<div v-if="!isSharingEnabled">
<n8n-action-box
:heading="
$locale.baseText(contextBasedTranslationKeys.credentials.sharing.unavailable.title)
"
:description="
$locale.baseText(contextBasedTranslationKeys.credentials.sharing.unavailable.description)
"
:buttonText="
$locale.baseText(contextBasedTranslationKeys.credentials.sharing.unavailable.button)
"
@click="goToUpgrade"
/>
</div>
<div v-else-if="isDefaultUser">
<n8n-action-box
:heading="$locale.baseText('credentialEdit.credentialSharing.isDefaultUser.title')"
:description="
$locale.baseText('credentialEdit.credentialSharing.isDefaultUser.description')
"
@ -23,8 +38,13 @@
</template>
</n8n-info-tip>
<n8n-info-tip
v-if="
!credentialPermissions.isOwner &&
!credentialPermissions.isSharee &&
credentialPermissions.isInstanceOwner
"
class="mb-s"
:bold="false"
v-if="!credentialPermissions.isOwner && credentialPermissions.isInstanceOwner"
>
{{ $locale.baseText('credentialEdit.credentialSharing.info.instanceOwner') }}
</n8n-info-tip>
@ -53,13 +73,16 @@
</template>
<script lang="ts">
import { IUser } from '@/Interface';
import { IUser, UIState } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { showMessage } from '@/mixins/showMessage';
import { mapStores } from 'pinia';
import { useUsersStore } from '@/stores/users';
import { useSettingsStore } from '@/stores/settings';
import { useUIStore } from '@/stores/ui';
import { useCredentialsStore } from '@/stores/credentials';
import { VIEWS } from '@/constants';
import { useUsageStore } from '@/stores/usage';
import { EnterpriseEditionFeature, VIEWS } from '@/constants';
export default mixins(showMessage).extend({
name: 'CredentialSharing',
@ -72,10 +95,16 @@ export default mixins(showMessage).extend({
'modalBus',
],
computed: {
...mapStores(useCredentialsStore, useUsersStore),
...mapStores(useCredentialsStore, useUsersStore, useUsageStore, useUIStore, useSettingsStore),
isDefaultUser(): boolean {
return this.usersStore.isDefaultUser;
},
contextBasedTranslationKeys(): UIState['contextBasedTranslationKeys'] {
return this.uiStore.contextBasedTranslationKeys;
},
isSharingEnabled(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
},
usersList(): IUser[] {
return this.usersStore.allUsers.filter((user: IUser) => {
const isCurrentUser = user.id === this.usersStore.currentUser?.id;
@ -138,6 +167,14 @@ export default mixins(showMessage).extend({
this.$router.push({ name: VIEWS.USERS_SETTINGS });
this.modalBus.$emit('close');
},
goToUpgrade() {
let linkUrl = this.$locale.baseText(this.contextBasedTranslationKeys.upgradeLinkUrl);
if (linkUrl.includes('subscription')) {
linkUrl = this.usageStore.viewPlansUrl;
}
window.open(linkUrl, '_blank');
},
},
mounted() {
this.loadUsers();

View file

@ -61,7 +61,7 @@
<span class="activator">
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" />
</span>
<enterprise-edition :features="[EnterpriseEditionFeature.WorkflowSharing]">
<enterprise-edition :features="[EnterpriseEditionFeature.Sharing]">
<n8n-button type="secondary" class="mr-2xs" @click="onShareButtonClick">
{{ $locale.baseText('workflowDetails.share') }}
</n8n-button>
@ -72,16 +72,17 @@
</n8n-button>
<template #content>
<i18n
:path="dynamicTranslations.workflows.sharing.unavailable.description"
:path="
contextBasedTranslationKeys.workflows.sharing.unavailable.description.tooltip
"
tag="span"
>
<template #action>
<a
:href="dynamicTranslations.workflows.sharing.unavailable.linkURL"
target="_blank"
>
<a @click="goToUpgrade">
{{
$locale.baseText(dynamicTranslations.workflows.sharing.unavailable.action)
$locale.baseText(
contextBasedTranslationKeys.workflows.sharing.unavailable.button,
)
}}
</a>
</template>
@ -139,13 +140,7 @@ import SaveButton from '@/components/SaveButton.vue';
import TagsDropdown from '@/components/TagsDropdown.vue';
import InlineTextEdit from '@/components/InlineTextEdit.vue';
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import {
IUser,
IWorkflowDataUpdate,
IWorkflowDb,
IWorkflowToShare,
NestedRecord,
} from '@/Interface';
import { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface';
import { saveAs } from 'file-saver';
import { titleChange } from '@/mixins/titleChange';
@ -158,6 +153,7 @@ import { useRootStore } from '@/stores/n8nRootStore';
import { useTagsStore } from '@/stores/tags';
import { getWorkflowPermissions, IPermissions } from '@/permissions';
import { useUsersStore } from '@/stores/users';
import { useUsageStore } from '@/stores/usage';
const hasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) {
@ -197,14 +193,15 @@ export default mixins(workflowHelpers, titleChange).extend({
useRootStore,
useSettingsStore,
useUIStore,
useUsageStore,
useWorkflowsStore,
useUsersStore,
),
currentUser(): IUser | null {
return this.usersStore.currentUser;
},
dynamicTranslations(): NestedRecord<string> {
return this.uiStore.dynamicTranslations;
contextBasedTranslationKeys(): NestedRecord<string> {
return this.uiStore.contextBasedTranslationKeys;
},
isWorkflowActive(): boolean {
return this.workflowsStore.isWorkflowActive;
@ -528,6 +525,14 @@ export default mixins(workflowHelpers, titleChange).extend({
break;
}
},
goToUpgrade() {
let linkUrl = this.$locale.baseText(this.contextBasedTranslationKeys.upgradeLinkUrl);
if (linkUrl.includes('subscription')) {
linkUrl = this.usageStore.viewPlansUrl;
}
window.open(linkUrl, '_blank');
},
},
watch: {
currentWorkflowId() {

View file

@ -377,7 +377,7 @@ export default mixins(
let hasForeignCredential = false;
if (
credentials &&
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing)
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)
) {
Object.values(credentials).forEach((credential) => {
if (

View file

@ -35,7 +35,7 @@
</div>
<template #append>
<div :class="$style.cardActions">
<enterprise-edition :features="[EnterpriseEditionFeature.WorkflowSharing]">
<enterprise-edition :features="[EnterpriseEditionFeature.Sharing]">
<n8n-badge v-if="workflowPermissions.isOwner" class="mr-xs" theme="tertiary" bold>
{{ $locale.baseText('workflows.item.owner') }}
</n8n-badge>

View file

@ -42,7 +42,7 @@
</n8n-select>
</el-col>
</el-row>
<div v-if="isWorkflowSharingEnabled">
<div v-if="isSharingEnabled">
<el-row>
<el-col :span="10" class="setting-name">
{{ $locale.baseText('workflowSettings.callerPolicy') + ':' }}
@ -84,6 +84,7 @@
</el-col>
<el-col :span="14">
<n8n-input
:placeholder="$locale.baseText('workflowSettings.callerIds.placeholder')"
type="text"
size="medium"
v-model="workflowSettings.callerIds"
@ -331,7 +332,9 @@ import { genericHelpers } from '@/mixins/genericHelpers';
import { showMessage } from '@/mixins/showMessage';
import {
ITimeoutHMS,
IUser,
IWorkflowDataUpdate,
IWorkflowDb,
IWorkflowSettings,
IWorkflowShortResponse,
WorkflowCallerPolicyDefaultOption,
@ -350,6 +353,8 @@ import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows';
import { useSettingsStore } from '@/stores/settings';
import { useRootStore } from '@/stores/n8nRootStore';
import useWorkflowsEEStore from '@/stores/workflows.ee';
import { useUsersStore } from '@/stores/users';
export default mixins(externalHooks, genericHelpers, restApi, showMessage).extend({
name: 'WorkflowSettings',
@ -389,7 +394,7 @@ export default mixins(externalHooks, genericHelpers, restApi, showMessage).exten
saveDataSuccessExecution: 'all',
saveExecutionProgress: false,
saveManualExecutions: false,
workflowCallerPolicy: '',
workflowCallerPolicy: 'workflowsFromSameOwner',
},
workflowCallerPolicyOptions: [] as Array<{ key: string; value: string }>,
saveDataErrorExecutionOptions: [] as Array<{ key: string; value: string }>,
@ -408,17 +413,34 @@ export default mixins(externalHooks, genericHelpers, restApi, showMessage).exten
},
computed: {
...mapStores(useRootStore, useSettingsStore, useWorkflowsStore),
...mapStores(
useRootStore,
useUsersStore,
useSettingsStore,
useWorkflowsStore,
useWorkflowsEEStore,
),
workflowName(): string {
return this.workflowsStore.workflowName;
},
workflowId(): string {
return this.workflowsStore.workflowId;
},
isWorkflowSharingEnabled(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(
EnterpriseEditionFeature.WorkflowSharing,
workflow(): IWorkflowDb {
return this.workflowsStore.workflow;
},
currentUser(): IUser | null {
return this.usersStore.currentUser;
},
isSharingEnabled(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
},
workflowOwnerName(): string {
const fallback = this.$locale.baseText(
'workflowSettings.callerPolicy.options.workflowsFromSameOwner.fallback',
);
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflowId}`, fallback);
},
},
async mounted() {
@ -518,19 +540,36 @@ export default mixins(externalHooks, genericHelpers, restApi, showMessage).exten
};
},
async loadWorkflowCallerPolicyOptions() {
const currentUserIsOwner = this.workflow.ownedBy?.id === this.currentUser?.id;
this.workflowCallerPolicyOptions = [
{
key: 'any',
value: this.$locale.baseText('workflowSettings.callerPolicy.options.any'),
},
{
key: 'none',
value: this.$locale.baseText('workflowSettings.callerPolicy.options.none'),
},
{
key: 'workflowsFromSameOwner',
value: this.$locale.baseText(
'workflowSettings.callerPolicy.options.workflowsFromSameOwner',
{
interpolate: {
owner: currentUserIsOwner
? this.$locale.baseText(
'workflowSettings.callerPolicy.options.workflowsFromSameOwner.owner',
)
: this.workflowOwnerName,
},
},
),
},
{
key: 'workflowsFromAList',
value: this.$locale.baseText('workflowSettings.callerPolicy.options.workflowsFromAList'),
},
{
key: 'any',
value: this.$locale.baseText('workflowSettings.callerPolicy.options.any'),
},
];
},
async loadSaveDataErrorExecutionOptions() {

View file

@ -1,18 +1,23 @@
<template>
<Modal
width="460px"
:title="
$locale.baseText(dynamicTranslations.workflows.shareModal.title, {
interpolate: { name: workflow.name },
})
"
:title="modalTitle"
:eventBus="modalBus"
:name="WORKFLOW_SHARE_MODAL_KEY"
:center="true"
:beforeClose="onCloseModal"
>
<template #content>
<div v-if="isDefaultUser" :class="$style.container">
<div v-if="!isSharingEnabled" :class="$style.container">
<n8n-text>
{{
$locale.baseText(
contextBasedTranslationKeys.workflows.sharing.unavailable.description.modal,
)
}}
</n8n-text>
</div>
<div v-else-if="isDefaultUser" :class="$style.container">
<n8n-text>
{{ $locale.baseText('workflows.shareModal.isDefaultUser.description') }}
</n8n-text>
@ -25,7 +30,7 @@
})
}}
</n8n-info-tip>
<enterprise-edition :features="[EnterpriseEditionFeature.WorkflowSharing]">
<enterprise-edition :features="[EnterpriseEditionFeature.Sharing]">
<n8n-user-select
v-if="workflowPermissions.updateSharing"
class="mb-s"
@ -66,7 +71,7 @@
<template #fallback>
<n8n-text>
<i18n
:path="dynamicTranslations.workflows.sharing.unavailable.description"
:path="contextBasedTranslationKeys.workflows.sharing.unavailable.description"
tag="span"
>
<template #action />
@ -78,14 +83,19 @@
</template>
<template #footer>
<div v-if="isDefaultUser" :class="$style.actionButtons">
<div v-if="!isSharingEnabled" :class="$style.actionButtons">
<n8n-button @click="goToUpgrade">
{{ $locale.baseText(contextBasedTranslationKeys.workflows.sharing.unavailable.button) }}
</n8n-button>
</div>
<div v-else-if="isDefaultUser" :class="$style.actionButtons">
<n8n-button @click="goToUsersSettings">
{{ $locale.baseText('workflows.shareModal.isDefaultUser.button') }}
</n8n-button>
</div>
<enterprise-edition
v-else
:features="[EnterpriseEditionFeature.WorkflowSharing]"
:features="[EnterpriseEditionFeature.Sharing]"
:class="$style.actionButtons"
>
<n8n-text v-show="isDirty" color="text-light" size="small" class="mr-xs">
@ -102,9 +112,11 @@
</n8n-button>
<template #fallback>
<n8n-link :to="dynamicTranslations.workflows.sharing.unavailable.linkURL">
<n8n-link :to="contextBasedTranslationKeys.workflows.sharing.unavailable.linkUrl">
<n8n-button :loading="loading" size="medium">
{{ $locale.baseText(dynamicTranslations.workflows.sharing.unavailable.button) }}
{{
$locale.baseText(contextBasedTranslationKeys.workflows.sharing.unavailable.button)
}}
</n8n-button>
</n8n-link>
</template>
@ -122,7 +134,7 @@ import {
VIEWS,
WORKFLOW_SHARE_MODAL_KEY,
} from '../constants';
import { IUser, IWorkflowDb, NestedRecord } from '@/Interface';
import { IUser, IWorkflowDb, UIState } from '@/Interface';
import { getWorkflowPermissions, IPermissions } from '@/permissions';
import mixins from 'vue-typed-mixins';
import { showMessage } from '@/mixins/showMessage';
@ -134,6 +146,7 @@ import { useUsersStore } from '@/stores/users';
import { useWorkflowsStore } from '@/stores/workflows';
import { useWorkflowsEEStore } from '@/stores/workflows.ee';
import { ITelemetryTrackProperties } from 'n8n-workflow';
import { useUsageStore } from '@/stores/usage';
export default mixins(showMessage).extend({
name: 'workflow-share-modal',
@ -166,12 +179,26 @@ export default mixins(showMessage).extend({
useSettingsStore,
useUIStore,
useUsersStore,
useUsageStore,
useWorkflowsStore,
useWorkflowsEEStore,
),
isDefaultUser(): boolean {
return this.usersStore.isDefaultUser;
},
isSharingEnabled(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
},
modalTitle(): string {
return this.$locale.baseText(
this.isSharingEnabled
? this.contextBasedTranslationKeys.workflows.sharing.title
: this.contextBasedTranslationKeys.workflows.sharing.unavailable.title,
{
interpolate: { name: this.workflow.name },
},
);
},
usersList(): IUser[] {
return this.usersStore.allUsers.filter((user: IUser) => {
const isCurrentUser = user.id === this.usersStore.currentUser?.id;
@ -208,14 +235,8 @@ export default mixins(showMessage).extend({
workflowOwnerName(): string {
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflow.id}`);
},
isSharingAvailable(): boolean {
return (
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing) ===
true
);
},
dynamicTranslations(): NestedRecord<string> {
return this.uiStore.dynamicTranslations;
contextBasedTranslationKeys(): UIState['contextBasedTranslationKeys'] {
return this.uiStore.contextBasedTranslationKeys;
},
isDirty(): boolean {
const previousSharedWith = this.workflow.sharedWith || [];
@ -240,10 +261,10 @@ export default mixins(showMessage).extend({
return new Promise<string>((resolve) => {
if (this.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
nodeViewEventBus.$emit('saveWorkflow', () => {
resolve(this.workflowsStore.workflowId);
resolve(this.workflow.id);
});
} else {
resolve(this.workflowsStore.workflowId);
resolve(this.workflow.id);
}
});
};
@ -411,9 +432,17 @@ export default mixins(showMessage).extend({
...data,
});
},
goToUpgrade() {
let linkUrl = this.$locale.baseText(this.contextBasedTranslationKeys.upgradeLinkUrl);
if (linkUrl.includes('subscription')) {
linkUrl = this.usageStore.viewPlansUrl;
}
window.open(linkUrl, '_blank');
},
},
mounted() {
if (this.isSharingAvailable) {
if (this.isSharingEnabled) {
this.loadUsers();
}
},

View file

@ -318,8 +318,6 @@ export enum VIEWS {
export enum FAKE_DOOR_FEATURES {
ENVIRONMENTS = 'environments',
LOGGING = 'logging',
CREDENTIALS_SHARING = 'credentialsSharing',
WORKFLOWS_SHARING = 'workflowsSharing',
}
export const ONBOARDING_PROMPT_TIMEBOX = 14;
@ -374,7 +372,6 @@ export enum WORKFLOW_MENU_ACTIONS {
*/
export enum EnterpriseEditionFeature {
Sharing = 'sharing',
WorkflowSharing = 'workflowSharing',
}
export const MAIN_NODE_PANEL_WIDTH = 360;

View file

@ -66,8 +66,8 @@ import { IWorkflowSettings } from 'n8n-workflow';
import { useNDVStore } from '@/stores/ndv';
import { useTemplatesStore } from '@/stores/templates';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useUsersStore } from '@/stores/users';
import { useWorkflowsEEStore } from '@/stores/workflows.ee';
import { useUsersStore } from '@/stores/users';
import { getWorkflowPermissions, IPermissions } from '@/permissions';
import { ICredentialsResponse } from '@/Interface';
@ -928,7 +928,7 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
this.workflowsStore.setWorkflowVersionId(workflowData.versionId);
if (
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing) &&
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing) &&
this.usersStore.currentUser
) {
this.workflowsEEStore.setWorkflowOwnedBy({

View file

@ -12,7 +12,7 @@ export enum UserRole {
InstanceOwner = 'isInstanceOwner',
ResourceOwner = 'isOwner',
ResourceEditor = 'isEditor',
ResourceReader = 'isReader',
ResourceSharee = 'isSharee',
}
export type IPermissions = Record<string, boolean>;
@ -65,7 +65,7 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
!isSharingEnabled,
},
{
name: UserRole.ResourceReader,
name: UserRole.ResourceSharee,
test: () =>
!!(
credential &&
@ -75,7 +75,7 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
},
{
name: 'read',
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceReader],
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee],
},
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
@ -83,7 +83,7 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
{ name: 'updateSharing', test: [UserRole.ResourceOwner] },
{ name: 'updateNodeAccess', test: [UserRole.ResourceOwner] },
{ name: 'delete', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'use', test: [UserRole.ResourceOwner, UserRole.ResourceReader] },
{ name: 'use', test: [UserRole.ResourceOwner, UserRole.ResourceSharee] },
];
return parsePermissionsTable(user, table);
@ -92,7 +92,7 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb) => {
const settingsStore = useSettingsStore();
const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled(
EnterpriseEditionFeature.WorkflowSharing,
EnterpriseEditionFeature.Sharing,
);
const isNewWorkflow = workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID;
@ -104,7 +104,7 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
!isSharingEnabled,
},
{
name: UserRole.ResourceReader,
name: UserRole.ResourceSharee,
test: () =>
!!(
workflow &&
@ -114,7 +114,7 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
},
{
name: 'read',
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceReader],
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee],
},
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
@ -124,7 +124,7 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
{ name: 'delete', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{
name: 'use',
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceReader],
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee],
},
];

View file

@ -256,6 +256,7 @@
"credentialEdit.credentialConfig.oAuthRedirectUrl": "OAuth Redirect URL",
"credentialEdit.credentialConfig.openDocs": "Open docs",
"credentialEdit.credentialConfig.pleaseCheckTheErrorsBelow": "Please check the errors below",
"credentialEdit.credentialConfig.pleaseCheckTheErrorsBelow.sharee": "Problem with connection settings. {owner} may be able to fix this",
"credentialEdit.credentialConfig.reconnect": "Reconnect",
"credentialEdit.credentialConfig.reconnectOAuth2Credential": "Reconnect OAuth2 Credential",
"credentialEdit.credentialConfig.redirectUrlCopiedToClipboard": "Redirect URL copied to clipboard",
@ -300,15 +301,16 @@
"credentialEdit.oAuthButton.connectMyAccount": "Connect my account",
"credentialEdit.oAuthButton.signInWithGoogle": "Sign in with Google",
"credentialEdit.credentialSharing.info.owner": "Sharing a credential allows people to use it in their workflows. They cannot access credential details.",
"credentialEdit.credentialSharing.info.instanceOwner": "You have access to this credential because youre the Instance Owner. You can view partial data, update the credential title, or delete the credential.",
"credentialEdit.credentialSharing.info.instanceOwner": "You can view this credential because you are the instance owner (and rename or delete it too). To use it in a workflow, ask the credential owner to share it with you.",
"credentialEdit.credentialSharing.info.sharee": "Only {credentialOwnerName} can change who this credential is shared with",
"credentialEdit.credentialSharing.info.sharee.fallback": "the owner",
"credentialEdit.credentialSharing.select.placeholder": "Add people",
"credentialEdit.credentialSharing.select.placeholder": "Add users...",
"credentialEdit.credentialSharing.list.delete": "Remove",
"credentialEdit.credentialSharing.list.delete.confirm.title": "Remove access?",
"credentialEdit.credentialSharing.list.delete.confirm.message": "This may break any workflows in which {name} has used this credential",
"credentialEdit.credentialSharing.list.delete.confirm.confirmButtonText": "Remove",
"credentialEdit.credentialSharing.list.delete.confirm.cancelButtonText": "Cancel",
"credentialEdit.credentialSharing.isDefaultUser.title": "Sharing",
"credentialEdit.credentialSharing.isDefaultUser.description": "You first need to set up your owner account to enable credential sharing features.",
"credentialEdit.credentialSharing.isDefaultUser.button": "Go to settings",
"credentialSelectModal.addNewCredential": "Add new credential",
@ -495,12 +497,6 @@
"expressionModalInput.empty": "[empty]",
"expressionModalInput.undefined": "[undefined]",
"expressionModalInput.null": "null",
"fakeDoor.credentialEdit.sharing.name": "Sharing",
"fakeDoor.credentialEdit.sharing.actionBox.title": "Sharing is only available on <a href=\"https://n8n.io/cloud/\" target=\"_blank\">n8n Cloud</a> right now",
"fakeDoor.credentialEdit.sharing.actionBox.description": "Were working on bringing it to this edition of n8n, as a paid feature. If youd like to be the first to hear when its ready, join the list.",
"fakeDoor.credentialEdit.sharing.actionBox.title.cloud.upgrade": "Upgrade to share credentials",
"fakeDoor.credentialEdit.sharing.actionBox.description.cloud.upgrade": "Power and Pro plan users can create multiple user accounts and collaborate on workflows",
"fakeDoor.credentialEdit.sharing.actionBox.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": "Were working on environments (as a paid feature)",
@ -513,8 +509,8 @@
"fakeDoor.settings.logging.actionBox.description": "This also includes audit logging. If you'd like to be the first to hear when it's ready, join the list.",
"fakeDoor.settings.users.name": "Users",
"fakeDoor.settings.users.actionBox.title": "Upgrade to add users",
"fakeDoor.settings.users.actionBox.description": "Power and Pro plan users can create multiple user accounts and share credentials. (Sharing workflows is coming soon)",
"fakeDoor.settings.users.actionBox.button": "Upgrade",
"fakeDoor.settings.users.actionBox.description": "Create multiple users on our higher plans and share workflows and credentials to collaborate",
"fakeDoor.settings.users.actionBox.button": "Upgrade now",
"fakeDoor.actionBox.button.label": "Join the list",
"fixedCollectionParameter.choose": "Choose...",
"fixedCollectionParameter.currentlyNoItemsExist": "Currently no items exist",
@ -1332,9 +1328,13 @@
"workflowRun.showError.title": "Problem running workflow",
"workflowRun.showMessage.message": "Please fix them before executing",
"workflowRun.showMessage.title": "Workflow has issues",
"workflowSettings.callerIds": "Caller IDs",
"workflowSettings.callerIds": "IDs of workflows that can call this one",
"workflowSettings.callerIds.placeholder": "e.g. 14, 18",
"workflowSettings.callerPolicy": "This workflow can be called by",
"workflowSettings.callerPolicy.options.any": "Any workflow",
"workflowSettings.callerPolicy.options.workflowsFromSameOwner": "Workflows created by {owner}",
"workflowSettings.callerPolicy.options.workflowsFromSameOwner.owner": "me",
"workflowSettings.callerPolicy.options.workflowsFromSameOwner.fallback": "same owner",
"workflowSettings.callerPolicy.options.workflowsFromAList": "Selected workflows",
"workflowSettings.callerPolicy.options.none": "No other workflows",
"workflowSettings.defaultTimezone": "Default - {defaultTimezoneValue}",
@ -1348,7 +1348,7 @@
"workflowSettings.helpTexts.saveExecutionProgress": "Whether to save data after each node execution. This allows you to resume from where execution stopped if there is an error, but may increase latency.",
"workflowSettings.helpTexts.saveManualExecutions": "Whether to save data of executions that are started manually from the editor",
"workflowSettings.helpTexts.timezone": "The timezone in which the workflow should run. Used by 'cron' node, for example.",
"workflowSettings.helpTexts.workflowCallerIds": "Comma-delimited list of IDs of workflows that are allowed to call this workflow",
"workflowSettings.helpTexts.workflowCallerIds": "The IDs of the workflows that are allowed to execute this one (using an execute workflow node). The ID can be found at the end of the workflows URL. Separate multiple IDs by commas.",
"workflowSettings.helpTexts.workflowCallerPolicy": "Workflows that are allowed to call this workflow using the Execute Workflow node",
"workflowSettings.hours": "hours",
"workflowSettings.minutes": "minutes",
@ -1423,7 +1423,7 @@
"workflows.empty.startFromScratch": "Start from scratch",
"workflows.empty.browseTemplates": "Browse templates",
"workflows.shareModal.title": "Share '{name}'",
"workflows.shareModal.select.placeholder": "Add people",
"workflows.shareModal.select.placeholder": "Add users...",
"workflows.shareModal.list.delete": "Remove access",
"workflows.shareModal.list.delete.confirm.title": "Remove {name}'s access?",
"workflows.shareModal.list.delete.confirm.lastUserWithAccessToCredentials.message": "If you do this, the workflow will lose access to {name}s credentials. <strong>Nodes that use those credentials will stop working</strong>.",
@ -1457,13 +1457,31 @@
"importParameter.showError.invalidProtocol1.title": "Use the {node} node",
"importParameter.showError.invalidProtocol2.title": "Invalid Protocol",
"importParameter.showError.invalidProtocol.message": "The HTTP node doesnt support {protocol} requests",
"dynamic.workflows.shareModal.title": "Share '{name}'",
"dynamic.workflows.shareModal.title.cloud.upgrade": "Upgrade to add users",
"dynamic.workflows.sharing.unavailable.description": "You can collaborate with others on workflows when you upgrade your plan. {action}",
"dynamic.workflows.sharing.unavailable.description.cloud.upgrade": "Sharing is available for Team and Enterprise plans. {action} to unlock more features.",
"dynamic.workflows.sharing.unavailable.action": "See plans",
"dynamic.workflows.sharing.unavailable.action.cloud.upgrade": "Upgrade now",
"dynamic.workflows.sharing.unavailable.button": "See plans",
"dynamic.workflows.sharing.unavailable.button.cloud.upgrade": "Upgrade now",
"dynamic.workflows.sharing.unavailable.linkUrl": "https://subscription.n8n.io/"
"contextual.credentials.sharing.unavailable.title": "Upgrade to collaborate",
"contextual.credentials.sharing.unavailable.title.cloud": "Upgrade to collaborate",
"contextual.credentials.sharing.unavailable.title.desktop": "Upgrade to n8n Cloud to collaborate",
"contextual.credentials.sharing.unavailable.description": "You can share credentials with others when you upgrade your plan.",
"contextual.credentials.sharing.unavailable.description.cloud": "You can share credentials with others when you upgrade your plan.",
"contextual.credentials.sharing.unavailable.description.desktop": "Sharing features are available on selected Cloud plans",
"contextual.credentials.sharing.unavailable.button": "View plans",
"contextual.credentials.sharing.unavailable.button.cloud": "Upgrade now",
"contextual.credentials.sharing.unavailable.button.desktop": "View plans",
"contextual.workflows.sharing.title": "Sharing",
"contextual.workflows.sharing.unavailable.title": "Sharing",
"contextual.workflows.sharing.unavailable.title.cloud": "Upgrade to collaborate",
"contextual.workflows.sharing.unavailable.title.desktop": "Upgrade to n8n Cloud to collaborate",
"contextual.workflows.sharing.unavailable.description.modal": "You can collaborate with others on workflows when you upgrade your plan.",
"contextual.workflows.sharing.unavailable.description.modal.cloud": "You can collaborate with others on workflows when you upgrade your plan.",
"contextual.workflows.sharing.unavailable.description.modal.desktop": "Upgrade to n8n Cloud to collaborate on workflows: sharing features are available on selected plans.",
"contextual.workflows.sharing.unavailable.description.tooltip": "You can collaborate with others on workflows when you upgrade your plan. {action}",
"contextual.workflows.sharing.unavailable.description.tooltip.cloud": "You can collaborate with others on workflows when you upgrade your plan. {action}",
"contextual.workflows.sharing.unavailable.description.tooltip.desktop": "Upgrade to n8n Cloud to collaborate on workflows: sharing features are available on selected plans. {action}",
"contextual.workflows.sharing.unavailable.button": "View plans",
"contextual.workflows.sharing.unavailable.button.cloud": "Upgrade now",
"contextual.workflows.sharing.unavailable.button.desktop": "View plans",
"contextual.upgradeLinkUrl": "https://subscription.n8n.io/",
"contextual.upgradeLinkUrl.cloud": "https://app.n8n.cloud/manage?edition=cloud",
"contextual.upgradeLinkUrl.desktop": "https://n8n.io/pricing"
}

View file

@ -53,7 +53,7 @@ function getTemplatesRedirect() {
const settingsStore = useSettingsStore();
const isTemplatesEnabled: boolean = settingsStore.isTemplatesEnabled;
if (!isTemplatesEnabled) {
return { name: VIEWS.NOT_FOUND };
return {name: VIEWS.NOT_FOUND};
}
return false;
@ -75,7 +75,7 @@ const router = new Router({
name: VIEWS.HOMEPAGE,
meta: {
getRedirect() {
return { name: VIEWS.WORKFLOWS };
return {name: VIEWS.WORKFLOWS};
},
permissions: {
allow: {
@ -450,7 +450,7 @@ const router = new Router({
deny: {
shouldDeny: () => {
const settingsStore = useSettingsStore();
return settingsStore.settings.hideUsagePage === true;
return settingsStore.settings.hideUsagePage === true || settingsStore.settings.deployment?.type === 'cloud';
},
},
},

View file

@ -66,6 +66,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
showSetupPage(): boolean {
return this.userManagement.showSetupOnFirstLoad === true;
},
deploymentType(): string {
return this.settings.deployment?.type || 'default';
},
isDesktopDeployment(): boolean {
if (!this.settings.deployment) {
return false;

View file

@ -44,6 +44,7 @@ import { defineStore } from 'pinia';
import { useRootStore } from './n8nRootStore';
import { getCurlToJson } from '@/api/curlHelper';
import { useWorkflowsStore } from './workflows';
import { useSettingsStore } from './settings';
export const useUIStore = defineStore(STORES.UI, {
state: (): UIState => ({
@ -144,39 +145,7 @@ export const useUIStore = defineStore(STORES.UI, {
linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=logging',
uiLocations: ['settings'],
},
{
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'],
},
],
dynamicTranslations: {
workflows: {
shareModal: {
title: 'dynamic.workflows.shareModal.title',
},
sharing: {
unavailable: {
description: 'dynamic.workflows.sharing.unavailable.description',
action: 'dynamic.workflows.sharing.unavailable.action',
button: 'dynamic.workflows.sharing.unavailable.button',
linkURL: 'https://n8n.cloud',
},
},
},
},
draggable: {
isDragging: false,
type: '',
@ -196,6 +165,45 @@ export const useUIStore = defineStore(STORES.UI, {
executionSidebarAutoRefresh: true,
}),
getters: {
contextBasedTranslationKeys() {
const settingsStore = useSettingsStore();
const deploymentType = settingsStore.deploymentType;
let contextKey = '';
if (deploymentType === 'cloud') {
contextKey = '.cloud';
} else if (deploymentType === 'desktop_mac' || deploymentType === 'desktop_win') {
contextKey = '.desktop';
}
return {
upgradeLinkUrl: `contextual.upgradeLinkUrl${contextKey}`,
credentials: {
sharing: {
unavailable: {
title: `contextual.credentials.sharing.unavailable.title${contextKey}`,
description: `contextual.credentials.sharing.unavailable.description${contextKey}`,
action: `contextual.credentials.sharing.unavailable.action${contextKey}`,
button: `contextual.credentials.sharing.unavailable.button${contextKey}`,
},
},
},
workflows: {
sharing: {
title: 'contextual.workflows.sharing.title',
unavailable: {
title: `contextual.workflows.sharing.unavailable.title${contextKey}`,
description: {
modal: `contextual.workflows.sharing.unavailable.description.modal${contextKey}`,
tooltip: `contextual.workflows.sharing.unavailable.description.tooltip${contextKey}`,
},
action: `contextual.workflows.sharing.unavailable.action${contextKey}`,
button: `contextual.workflows.sharing.unavailable.button${contextKey}`,
},
},
},
};
},
getLastSelectedNode(): INodeUi | null {
const workflowsStore = useWorkflowsStore();
if (this.lastSelectedNode) {

View file

@ -15,8 +15,8 @@ export const useWebhooksStore = defineStore(STORES.WEBHOOKS, {
globalRoleName(): string {
return useUsersStore().globalRoleName;
},
getDynamicTranslations() {
return useUIStore().dynamicTranslations;
getContextBasedTranslationKeys() {
return useUIStore().contextBasedTranslationKeys;
},
getFakeDoorFeatures() {
return useUIStore().fakeDoorFeatures;
@ -66,8 +66,8 @@ export const useWebhooksStore = defineStore(STORES.WEBHOOKS, {
setFakeDoorFeatures(fakeDoors: IFakeDoor[]): void {
useUIStore().fakeDoorFeatures = fakeDoors;
},
setDynamicTranslations(translations: NestedRecord<string>): void {
useUIStore().dynamicTranslations = translations;
setContextBasedTranslationKeys(translations: NestedRecord<string>): void {
useUIStore().contextBasedTranslationKeys = translations;
},
},
});

View file

@ -14,11 +14,14 @@ export const useWorkflowsEEStore = defineStore(STORES.WORKFLOWS_EE, {
},
getters: {
getWorkflowOwnerName() {
return (workflowId: string): string => {
return (
workflowId: string,
fallback = i18n.baseText('workflows.shareModal.info.sharee.fallback'),
): string => {
const workflow = useWorkflowsStore().getWorkflowById(workflowId);
return workflow && workflow.ownedBy && workflow.ownedBy.firstName
? `${workflow.ownedBy.firstName} ${workflow.ownedBy.lastName} (${workflow.ownedBy.email})`
: i18n.baseText('workflows.shareModal.info.sharee.fallback');
: fallback;
};
},
},
@ -67,7 +70,7 @@ export const useWorkflowsEEStore = defineStore(STORES.WORKFLOWS_EE, {
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing)) {
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) {
await setWorkflowSharedWith(rootStore.getRestApiContext, payload.workflowId, {
shareWithIds: payload.sharedWith.map((sharee) => sharee.id as string),
});

View file

@ -278,7 +278,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
this.workflow = createEmptyWorkflow();
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing)) {
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) {
Vue.set(this.workflow, 'ownedBy', usersStore.currentUser);
}
},

View file

@ -2684,7 +2684,7 @@ export default mixins(
if (
newNodeData.credentials &&
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowSharing)
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)
) {
const usedCredentials = this.workflowsStore.usedCredentials;
newNodeData.credentials = Object.fromEntries(

View file

@ -153,9 +153,7 @@ export default mixins(showMessage, debounceHelper).extend({
return this.workflowsStore.allWorkflows;
},
isShareable(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(
EnterpriseEditionFeature.WorkflowSharing,
);
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
},
statusFilterOptions(): Array<{ label: string; value: string | boolean }> {
return [

View file

@ -1657,7 +1657,7 @@ export interface IWorkflowExecuteAdditionalData {
executeWorkflow: (
workflowInfo: IExecuteWorkflowInfo,
additionalData: IWorkflowExecuteAdditionalData,
options?: {
options: {
parentWorkflowId?: string;
inputData?: INodeExecutionData[];
parentExecutionId?: string;