1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-10-05 17:17:45 +03:00

Merge remote-tracking branch 'origin/master' into ENG-5-db-locking-for-migrations

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2022-12-22 17:13:29 +01:00
commit 3523813fca
158 changed files with 1243 additions and 677 deletions

View File

@ -0,0 +1,41 @@
name: Check Documentation URLs
on:
push:
tags:
- n8n@*
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.4
- uses: actions/setup-node@v3
with:
node-version: 16.x
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build nodes-base
run: pnpm --filter n8n-workflow --filter=n8n-core --filter=n8n-nodes-base build
- name: Test URLS
run: node scripts/validate-docs-links.js
- name: Notify Slack on failure
uses: act10ns/slack@v2.0.0
if: failure()
with:
status: ${{ job.status }}
channel: '#updates-build-alerts'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: Documentation URLs check failed (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})

View File

@ -6,6 +6,13 @@
"dist": true,
"pnpm-lock.yaml": true
},
"typescript.format.enable": false,
"typescript.tsdk": "node_modules/typescript/lib",
"workspace-default-settings.runOnActivation": true
"workspace-default-settings.runOnActivation": true,
"eslint.probe": ["javascript", "typescript", "vue"],
"eslint.workingDirectories": [
{
"mode": "auto"
}
]
}

View File

@ -1,3 +1,27 @@
# [0.209.0](https://github.com/n8n-io/n8n/compare/n8n@0.208.1...n8n@0.209.0) (2022-12-21)
### Bug Fixes
* **editor:** Correctly display trigger nodes without actions and with related regular node in the "On App Events" category ([#4976](https://github.com/n8n-io/n8n/issues/4976)) ([445463a](https://github.com/n8n-io/n8n/commit/445463a605f5f327f897b23a9b4504939358d0df))
* Fix stickies resize ([#4986](https://github.com/n8n-io/n8n/issues/4986)) ([82f7635](https://github.com/n8n-io/n8n/commit/82f763589b21815e5ba91c10a4676d25f843eddd))
* Hide trigger tooltip for nodes with static test output ([#4970](https://github.com/n8n-io/n8n/issues/4970)) ([5b11dc3](https://github.com/n8n-io/n8n/commit/5b11dc3ff9ff75eb7c65721e0d6c03707039e7ff))
* Keep expression when dropping mapped value ([#4981](https://github.com/n8n-io/n8n/issues/4981)) ([87c7643](https://github.com/n8n-io/n8n/commit/87c76434a294f10474711ba3f023f2f4ca47f14d))
* Prevent keyboard shortcuts in expression editor modal ([#4984](https://github.com/n8n-io/n8n/issues/4984)) ([29364ea](https://github.com/n8n-io/n8n/commit/29364ea7026e5e2a288b1866956f01e380ff05a0))
* Redirect home to workflows always ([#4968](https://github.com/n8n-io/n8n/issues/4968)) ([90bfdfd](https://github.com/n8n-io/n8n/commit/90bfdfd577c02aee520545cf8758019042cdf99c))
* Update mapping gifs ([#4982](https://github.com/n8n-io/n8n/issues/4982)) ([9d00b47](https://github.com/n8n-io/n8n/commit/9d00b4748b39f5c2b08721c5ee73e47b43230b9d))
* Upgrade amqplib to address CVE-2022-0686 ([#4972](https://github.com/n8n-io/n8n/issues/4972)) ([570ed3b](https://github.com/n8n-io/n8n/commit/570ed3b52191cf3a162fcdaaabc8ab15fb0ef08c))
* View option for binary-data shouldn't download the file on Chrome/Edge ([#4995](https://github.com/n8n-io/n8n/issues/4995)) ([e225c31](https://github.com/n8n-io/n8n/commit/e225c3190ea4cb5f68f642aab455ed0044fdecf9))
### Features
* Add PR template ([#4983](https://github.com/n8n-io/n8n/issues/4983)) ([17311ca](https://github.com/n8n-io/n8n/commit/17311ca0499d90c56013e3f79458c49c0ae3dcdc))
* **editor:** Add usage and plan pages ([#4819](https://github.com/n8n-io/n8n/issues/4819)) ([0da338f](https://github.com/n8n-io/n8n/commit/0da338f9b5f850b25e97383ae1f4cec8d0e4c17b)), closes [#4793](https://github.com/n8n-io/n8n/issues/4793) [#4842](https://github.com/n8n-io/n8n/issues/4842) [#4866](https://github.com/n8n-io/n8n/issues/4866) [#4875](https://github.com/n8n-io/n8n/issues/4875) [#4958](https://github.com/n8n-io/n8n/issues/4958) [#4979](https://github.com/n8n-io/n8n/issues/4979)
* Update mapping pill for table/json views ([#4965](https://github.com/n8n-io/n8n/issues/4965)) ([343f53b](https://github.com/n8n-io/n8n/commit/343f53bf5393e86eb850d07de85b762476294656))
## [0.208.1](https://github.com/n8n-io/n8n/compare/n8n@0.208.0...n8n@0.208.1) (2022-12-19)

View File

@ -6,7 +6,7 @@ COPY .npmrc /usr/local/etc/npmrc
RUN \
apk add --update git graphicsmagick tini tzdata ca-certificates && \
npm install -g npm@latest full-icu && \
npm install -g npm@8.19.2 full-icu && \
rm -rf /var/cache/apk/* /root/.npm /tmp/* && \
# Install fonts
apk --no-cache add --virtual fonts msttcorefonts-installer fontconfig && \

View File

@ -22,7 +22,8 @@ RUN rm -rf patches .npmrc *.yaml node_modules/.cache packages/**/node_modules/.c
# 2. Start with a new clean image with just the code that is needed to run n8n
FROM n8nio/base:${NODE_VERSION}
COPY --from=builder /home/node ./
COPY --from=builder /home/node /usr/local/lib/node_modules/n8n
RUN ln -s /usr/local/lib/node_modules/n8n/packages/cli/bin/n8n /usr/local/bin/n8n
COPY docker/images/n8n-custom/docker-entrypoint.sh /
RUN \

View File

@ -1,16 +1,8 @@
#!/bin/sh
if [ "$#" -gt 0 ]; then
# Got started with arguments
COMMAND=$1;
if [[ "$COMMAND" == "n8n" ]]; then
shift
(cd packages/cli; exec node ./bin/n8n "$@")
else
exec node "$@"
fi
node "$@"
else
# Got started without arguments
cd packages/cli; exec node ./bin/n8n
# Got started without arguments
n8n
fi

View File

@ -12,7 +12,7 @@ RUN \
# Set a custom user to not have n8n run as root
USER root
RUN npm_config_user=root npm install -g npm@latest full-icu n8n@${N8N_VERSION}
RUN npm_config_user=root npm install -g npm@8.19.2 full-icu n8n@${N8N_VERSION}
ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu

View File

@ -17,7 +17,7 @@ RUN \
# Set a custom user to not have n8n run as root
USER root
RUN npm_config_user=root npm install -g npm@latest n8n@${N8N_VERSION}
RUN npm_config_user=root npm install -g npm@8.19.2 n8n@${N8N_VERSION}
WORKDIR /data

View File

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.208.1",
"version": "0.209.0",
"private": true,
"homepage": "https://n8n.io",
"engines": {
@ -66,7 +66,8 @@
"browserslist": "^4.21.4",
"ejs": "^3.1.8",
"fork-ts-checker-webpack-plugin": "^6.0.4",
"globby": "^11.1.0"
"cpy@8>globby": "^11.1.0",
"qqjs>globby": "^11.1.0"
}
}
}

View File

@ -2,12 +2,6 @@
* @type {import('@types/eslint').ESLint.ConfigData}
*/
const config = (module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
project: ['./tsconfig.json'],
},
ignorePatterns: [
'node_modules/**',
'dist/**',
@ -318,11 +312,21 @@ const config = (module.exports = {
// eslint-plugin-import
// ----------------------------------
/**
* https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/no-cycle.md
*/
'import/no-cycle': 'error',
/**
* https://github.com/import-js/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
*/
'import/no-default-export': 'error',
/**
* https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/no-unresolved.md
*/
'import/no-unresolved': 'error',
/**
* https://github.com/import-js/eslint-plugin-import/blob/master/docs/rules/order.md
*/

View File

@ -12,16 +12,6 @@ module.exports = {
node: true,
},
parser: 'vue-eslint-parser',
parserOptions: {
parser: {
ts: '@typescript-eslint/parser',
js: '@typescript-eslint/parser',
vue: 'vue-eslint-parser',
template: 'vue-eslint-parser',
},
},
ignorePatterns: ['**/*.js', '**/*.d.ts', 'vite.config.ts', '**/*.ts.snap'],
rules: {

View File

@ -10,6 +10,7 @@
"eslint": "~8.28",
"eslint-config-airbnb-typescript": "~17.0",
"eslint-config-prettier": "~8.5",
"eslint-import-resolver-typescript": "~3.5",
"eslint-plugin-diff": "~2.0",
"eslint-plugin-import": "~2.26",
"eslint-plugin-n8n-local-rules": "~1.0",

View File

@ -0,0 +1,41 @@
/**
* @type {(dir: string, mode: 'frontend' | undefined) => import('@types/eslint').ESLint.ConfigData}
*/
exports.sharedOptions = (tsconfigRootDir, mode) => {
const isFrontend = mode === 'frontend';
const parser = isFrontend ? 'vue-eslint-parser' : '@typescript-eslint/parser';
const extraParserOptions = isFrontend
? {
extraFileExtensions: ['.vue'],
parser: {
ts: '@typescript-eslint/parser',
js: '@typescript-eslint/parser',
vue: 'vue-eslint-parser',
template: 'vue-eslint-parser',
},
}
: {};
const settings = {
'import/parsers': {
'@typescript-eslint/parser': ['.ts'],
},
'import/resolver': {
typescript: {
tsconfigRootDir,
project: './tsconfig.json',
},
},
};
return {
parser,
parserOptions: {
tsconfigRootDir,
project: ['./tsconfig.json'],
...extraParserOptions,
},
settings,
};
};

View File

@ -1,13 +1,12 @@
const { sharedOptions } = require('@n8n_io/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
...sharedOptions(__dirname),
ignorePatterns: [
'jest.config.js',
@ -15,8 +14,10 @@ module.exports = {
'src/databases/migrations/**',
'src/databases/ormconfig.ts',
],
rules: {
// TODO: Remove this
'import/no-cycle': 'warn',
'import/order': 'off',
'import/extensions': 'off',
'@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': true }],

View File

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.208.1",
"version": "0.209.0",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -150,10 +150,10 @@
"lodash.split": "^4.4.2",
"lodash.unset": "^4.5.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.148.1",
"n8n-editor-ui": "~0.174.1",
"n8n-nodes-base": "~0.206.1",
"n8n-workflow": "~0.130.0",
"n8n-core": "~0.149.0",
"n8n-editor-ui": "~0.175.0",
"n8n-nodes-base": "~0.207.0",
"n8n-workflow": "~0.131.0",
"nodemailer": "^6.7.1",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",

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

@ -10,7 +10,7 @@ import type { ICredentialsDb } from '@/Interfaces';
import { CredentialsEntity } from '@db/entities/CredentialsEntity';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { User } from '@db/entities/User';
import { externalHooks } from '@/Server';
import { ExternalHooks } from '@/ExternalHooks';
import { IDependency, IJsonSchema } from '../../../types';
import { CredentialRequest } from '@/requests';
@ -74,7 +74,7 @@ export async function saveCredential(
scope: 'credential',
});
await externalHooks.run('credentials.create', [encryptedData]);
await ExternalHooks().run('credentials.create', [encryptedData]);
return Db.transaction(async (transactionManager) => {
const savedCredential = await transactionManager.save<CredentialsEntity>(credential);
@ -96,7 +96,7 @@ export async function saveCredential(
}
export async function removeCredential(credentials: CredentialsEntity): Promise<ICredentialsDb> {
await externalHooks.run('credentials.delete', [credentials.id]);
await ExternalHooks().run('credentials.delete', [credentials.id]);
return Db.collections.Credentials.remove(credentials);
}

View File

@ -7,7 +7,7 @@ import * as ActiveWorkflowRunner from '@/ActiveWorkflowRunner';
import config from '@/config';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { InternalHooksManager } from '@/InternalHooksManager';
import { externalHooks } from '@/Server';
import { ExternalHooks } from '@/ExternalHooks';
import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers';
import { WorkflowRequest } from '../../../types';
import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
@ -49,7 +49,7 @@ export = {
const createdWorkflow = await createWorkflow(workflow, req.user, role);
await externalHooks.run('workflow.afterCreate', [createdWorkflow]);
await ExternalHooks().run('workflow.afterCreate', [createdWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, createdWorkflow, true);
return res.json(createdWorkflow);
@ -76,7 +76,7 @@ export = {
await Db.collections.Workflow.delete(id);
void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, id.toString(), true);
await externalHooks.run('workflow.afterDelete', [id.toString()]);
await ExternalHooks().run('workflow.afterDelete', [id.toString()]);
return res.json(sharedWorkflow.workflow);
},
@ -219,7 +219,7 @@ export = {
const updatedWorkflow = await getWorkflowById(sharedWorkflow.workflowId);
await externalHooks.run('workflow.afterUpdate', [updateData]);
await ExternalHooks().run('workflow.afterUpdate', [updateData]);
void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updateData, true);
return res.json(updatedWorkflow);

View File

@ -165,7 +165,7 @@ require('body-parser-xml')(bodyParser);
const exec = promisify(callbackExec);
export const externalHooks: IExternalHooksClass = ExternalHooks();
const externalHooks: IExternalHooksClass = ExternalHooks();
class App {
app: express.Application;
@ -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,
});
@ -1515,7 +1513,9 @@ class App {
identifier,
);
if (mimeType) res.setHeader('Content-Type', mimeType);
if (fileName) res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
if (req.query.mode === 'download' && fileName) {
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
}
res.setHeader('Content-Length', fileSize);
res.sendFile(binaryPath);
},

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');
}
@ -53,7 +61,7 @@ export class PermissionChecker {
credentialsWhereCondition,
);
const accessibleCredIds = credentialSharings.map((s) => s.credentialId.toString());
const accessibleCredIds = credentialSharings.map((s) => s.credentialsId.toString());
const inaccessibleCredIds = workflowCredIds.filter((id) => !accessibleCredIds.includes(id));
@ -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,
workflowId: In(sharedWorkflowIds),
});
// 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.credentialsId.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({
relations,
where: whereClause({
user,
entityType: 'workflow',
entityId: workflowInfo.id,
}),
});
workflowData = shared?.workflow;
workflowData = await WorkflowsService.get(
{ id: parseInt(workflowInfo.id, 10) },
{
relations,
},
);
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

@ -2,13 +2,13 @@ import { User } from '@/databases/entities/User';
import { whereClause } from '@/UserManagement/UserManagementHelper';
import express from 'express';
import { LoggerProxy } from 'n8n-workflow';
import {
Db,
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
import type {
IWorkflowStatisticsCounts,
IWorkflowStatisticsDataLoaded,
IWorkflowStatisticsTimestamps,
ResponseHelper,
} from '..';
} from '@/Interfaces';
import { StatisticsNames } from '../databases/entities/WorkflowStatistics';
import { getLogger } from '../Logger';
import { ExecutionRequest } from '../requests';
@ -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

@ -223,8 +223,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',
},
},
@ -891,12 +891,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

@ -35,7 +35,13 @@ EECredentialsController.get(
});
// eslint-disable-next-line @typescript-eslint/unbound-method
return allCredentials.map(EECredentials.addOwnerAndSharings);
return allCredentials
.map((credential: CredentialsEntity & CredentialWithSharings) =>
EECredentials.addOwnerAndSharings(credential),
)
.map(
(credential): CredentialWithSharings => ({ ...credential, id: credential.id.toString() }),
);
} catch (error) {
LoggerProxy.error('Request to list credentials failed', error as Error);
throw error;

View File

@ -1,14 +1,7 @@
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/no-unused-vars */
import express from 'express';
import {
deepCopy,
ICredentialType,
INodeCredentialTestResult,
LoggerProxy,
NodeHelpers,
} from 'n8n-workflow';
import { Credentials } from 'n8n-core';
import { deepCopy, INodeCredentialTestResult, LoggerProxy } from 'n8n-workflow';
import * as GenericHelpers from '@/GenericHelpers';
import { InternalHooksManager } from '@/InternalHooksManager';
@ -17,7 +10,6 @@ import config from '@/config';
import { getLogger } from '@/Logger';
import { EECredentialsController } from './credentials.controller.ee';
import { CredentialsService } from './credentials.service';
import { CredentialTypes } from '@/CredentialTypes';
import type { ICredentialsResponse } from '@/Interfaces';
import type { CredentialRequest } from '@/requests';

View File

@ -20,7 +20,7 @@ import { CREDENTIAL_BLANKING_VALUE, RESPONSE_ERROR_MESSAGES } from '@/constants'
import { CredentialsEntity } from '@db/entities/CredentialsEntity';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { validateEntity } from '@/GenericHelpers';
import { externalHooks } from '../Server';
import { ExternalHooks } from '@/ExternalHooks';
import type { User } from '@db/entities/User';
import type { CredentialRequest } from '@/requests';
@ -80,7 +80,7 @@ export class CredentialsService {
select: SELECT_FIELDS,
relations: options?.relations,
where: {
id: In(userSharings.map((x) => x.credentialId)),
id: In(userSharings.map((x) => x.credentialsId)),
},
});
}
@ -234,7 +234,7 @@ export class CredentialsService {
credentialId: string,
newCredentialData: ICredentialsDb,
): Promise<ICredentialsDb | undefined> {
await externalHooks.run('credentials.update', [newCredentialData]);
await ExternalHooks().run('credentials.update', [newCredentialData]);
// Update the credentials in DB
await Db.collections.Credentials.update(credentialId, newCredentialData);
@ -253,7 +253,7 @@ export class CredentialsService {
const newCredential = new CredentialsEntity();
Object.assign(newCredential, credential, encryptedData);
await externalHooks.run('credentials.create', [encryptedData]);
await ExternalHooks().run('credentials.create', [encryptedData]);
const role = await Db.collections.Role.findOneOrFail({
name: 'owner',
@ -285,7 +285,7 @@ export class CredentialsService {
}
static async delete(credentials: CredentialsEntity): Promise<void> {
await externalHooks.run('credentials.delete', [credentials.id]);
await ExternalHooks().run('credentials.delete', [credentials.id]);
await Db.collections.Credentials.remove(credentials);
}

View File

@ -27,7 +27,7 @@ import {
} from '@/CredentialsHelper';
import { getLogger } from '@/Logger';
import { OAuthRequest } from '@/requests';
import { externalHooks } from '@/Server';
import { ExternalHooks } from '@/ExternalHooks';
import config from '@/config';
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
@ -129,7 +129,7 @@ oauth2CredentialController.get(
state: stateEncodedStr,
};
await externalHooks.run('oauth2.authenticate', [oAuthOptions]);
await ExternalHooks().run('oauth2.authenticate', [oAuthOptions]);
const oAuthObj = new ClientOAuth2(oAuthOptions);
@ -281,7 +281,7 @@ oauth2CredentialController.get(
delete oAuth2Parameters.clientSecret;
}
await externalHooks.run('oauth2.callback', [oAuth2Parameters]);
await ExternalHooks().run('oauth2.callback', [oAuth2Parameters]);
const oAuthObj = new ClientOAuth2(oAuth2Parameters);

View File

@ -1,7 +1,7 @@
import type { ICredentialNodeAccess } from 'n8n-workflow';
import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { IsArray, IsObject, IsString, Length } from 'class-validator';
import { SharedCredentials } from './SharedCredentials';
import type { SharedCredentials } from './SharedCredentials';
import { AbstractEntity, jsonColumnType } from './AbstractEntity';
import type { ICredentialsDb } from '@/Interfaces';
@ -28,7 +28,7 @@ export class CredentialsEntity extends AbstractEntity implements ICredentialsDb
})
type: string;
@OneToMany(() => SharedCredentials, (sharedCredentials) => sharedCredentials.credentials)
@OneToMany('SharedCredentials', 'credentials')
shared: SharedCredentials[];
@Column(jsonColumnType)

View File

@ -1,5 +1,5 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { InstalledPackages } from './InstalledPackages';
import type { InstalledPackages } from './InstalledPackages';
@Entity()
export class InstalledNodes {
@ -12,10 +12,7 @@ export class InstalledNodes {
@Column()
latestVersion: string;
@ManyToOne(
() => InstalledPackages,
(installedPackages: InstalledPackages) => installedPackages.installedNodes,
)
@ManyToOne('InstalledPackages', 'installedNodes')
@JoinColumn({ name: 'package', referencedColumnName: 'packageName' })
package: InstalledPackages;
}

View File

@ -1,5 +1,5 @@
import { Column, Entity, JoinColumn, OneToMany, PrimaryColumn } from 'typeorm';
import { InstalledNodes } from './InstalledNodes';
import type { InstalledNodes } from './InstalledNodes';
import { AbstractEntity } from './AbstractEntity';
@Entity()
@ -16,7 +16,7 @@ export class InstalledPackages extends AbstractEntity {
@Column()
authorEmail?: string;
@OneToMany(() => InstalledNodes, (installedNode) => installedNode.package)
@OneToMany('InstalledNodes', 'package')
@JoinColumn({ referencedColumnName: 'package' })
installedNodes: InstalledNodes[];
}

View File

@ -1,9 +1,9 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { IsString, Length } from 'class-validator';
import { User } from './User';
import { SharedWorkflow } from './SharedWorkflow';
import { SharedCredentials } from './SharedCredentials';
import type { User } from './User';
import type { SharedWorkflow } from './SharedWorkflow';
import type { SharedCredentials } from './SharedCredentials';
import { AbstractEntity } from './AbstractEntity';
export type RoleNames = 'owner' | 'member' | 'user' | 'editor';
@ -23,12 +23,12 @@ export class Role extends AbstractEntity {
@Column()
scope: RoleScopes;
@OneToMany(() => User, (user) => user.globalRole)
@OneToMany('User', 'globalRole')
globalForUsers: User[];
@OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.role)
@OneToMany('SharedWorkflow', 'role')
sharedWorkflows: SharedWorkflow[];
@OneToMany(() => SharedCredentials, (sharedCredentials) => sharedCredentials.role)
@OneToMany('SharedCredentials', 'role')
sharedCredentials: SharedCredentials[];
}

View File

@ -1,26 +1,28 @@
import { Entity, ManyToOne, RelationId } from 'typeorm';
import { CredentialsEntity } from './CredentialsEntity';
import { User } from './User';
import { Role } from './Role';
import { Entity, ManyToOne, PrimaryColumn, RelationId } from 'typeorm';
import type { CredentialsEntity } from './CredentialsEntity';
import type { User } from './User';
import type { Role } from './Role';
import { AbstractEntity } from './AbstractEntity';
@Entity()
export class SharedCredentials extends AbstractEntity {
@ManyToOne(() => Role, (role) => role.sharedCredentials, { nullable: false })
@ManyToOne('Role', 'sharedCredentials', { nullable: false })
role: Role;
@ManyToOne(() => User, (user) => user.sharedCredentials, { primary: true })
@ManyToOne('User', 'sharedCredentials', { primary: true })
user: User;
@PrimaryColumn()
@RelationId((sharedCredential: SharedCredentials) => sharedCredential.user)
userId: string;
@ManyToOne(() => CredentialsEntity, (credentials) => credentials.shared, {
@ManyToOne('CredentialsEntity', 'shared', {
primary: true,
onDelete: 'CASCADE',
})
credentials: CredentialsEntity;
@PrimaryColumn()
@RelationId((sharedCredential: SharedCredentials) => sharedCredential.credentials)
credentialId: number;
credentialsId: number;
}

View File

@ -1,26 +1,28 @@
import { Entity, ManyToOne, RelationId } from 'typeorm';
import { WorkflowEntity } from './WorkflowEntity';
import { User } from './User';
import { Role } from './Role';
import { Entity, ManyToOne, PrimaryColumn, RelationId } from 'typeorm';
import type { WorkflowEntity } from './WorkflowEntity';
import type { User } from './User';
import type { Role } from './Role';
import { AbstractEntity } from './AbstractEntity';
@Entity()
export class SharedWorkflow extends AbstractEntity {
@ManyToOne(() => Role, (role) => role.sharedWorkflows, { nullable: false })
@ManyToOne('Role', 'sharedWorkflows', { nullable: false })
role: Role;
@ManyToOne(() => User, (user) => user.sharedWorkflows, { primary: true })
@ManyToOne('User', 'sharedWorkflows', { primary: true })
user: User;
@PrimaryColumn()
@RelationId((sharedWorkflow: SharedWorkflow) => sharedWorkflow.user)
userId: string;
@ManyToOne(() => WorkflowEntity, (workflow) => workflow.shared, {
@ManyToOne('WorkflowEntity', 'shared', {
primary: true,
onDelete: 'CASCADE',
})
workflow: WorkflowEntity;
@PrimaryColumn()
@RelationId((sharedWorkflow: SharedWorkflow) => sharedWorkflow.workflow)
workflowId: number;
}

View File

@ -1,9 +1,9 @@
import { Column, Entity, Generated, Index, ManyToMany, PrimaryColumn } from 'typeorm';
import { IsString, Length } from 'class-validator';
import { ITagDb } from '@/Interfaces';
import type { ITagDb } from '@/Interfaces';
import { idStringifier } from '../utils/transformers';
import { WorkflowEntity } from './WorkflowEntity';
import type { WorkflowEntity } from './WorkflowEntity';
import { AbstractEntity } from './AbstractEntity';
@Entity()
@ -20,6 +20,6 @@ export class TagEntity extends AbstractEntity implements ITagDb {
@Length(1, 24, { message: 'Tag name must be $constraint1 to $constraint2 characters long.' })
name: string;
@ManyToMany(() => WorkflowEntity, (workflow) => workflow.tags)
@ManyToMany('WorkflowEntity', 'tags')
workflows: WorkflowEntity[];
}

View File

@ -12,9 +12,9 @@ import {
} from 'typeorm';
import { IsEmail, IsString, Length } from 'class-validator';
import type { IUser } from 'n8n-workflow';
import { Role } from './Role';
import { SharedWorkflow } from './SharedWorkflow';
import { SharedCredentials } from './SharedCredentials';
import type { Role } from './Role';
import type { SharedWorkflow } from './SharedWorkflow';
import type { SharedCredentials } from './SharedCredentials';
import { NoXss } from '../utils/customValidators';
import { objectRetriever, lowerCaser } from '../utils/transformers';
import { AbstractEntity, jsonColumnType } from './AbstractEntity';
@ -74,16 +74,16 @@ export class User extends AbstractEntity implements IUser {
})
settings: IUserSettings | null;
@ManyToOne(() => Role, (role) => role.globalForUsers, {
@ManyToOne('Role', 'globalForUsers', {
cascade: true,
nullable: false,
})
globalRole: Role;
@OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.user)
@OneToMany('SharedWorkflow', 'user')
sharedWorkflows: SharedWorkflow[];
@OneToMany(() => SharedCredentials, (sharedCredentials) => sharedCredentials.user)
@OneToMany('SharedCredentials', 'user')
sharedCredentials: SharedCredentials[];
@BeforeInsert()

View File

@ -21,11 +21,11 @@ import {
} from 'typeorm';
import config from '@/config';
import { TagEntity } from './TagEntity';
import { SharedWorkflow } from './SharedWorkflow';
import type { TagEntity } from './TagEntity';
import type { SharedWorkflow } from './SharedWorkflow';
import type { WorkflowStatistics } from './WorkflowStatistics';
import { objectRetriever, sqlite } from '../utils/transformers';
import { AbstractEntity, jsonColumnType } from './AbstractEntity';
import { WorkflowStatistics } from './WorkflowStatistics';
import type { IWorkflowDb } from '@/Interfaces';
@Entity()
@ -63,7 +63,7 @@ export class WorkflowEntity extends AbstractEntity implements IWorkflowDb {
})
staticData?: IDataObject;
@ManyToMany(() => TagEntity, (tag) => tag.workflows)
@ManyToMany('TagEntity', 'workflows')
@JoinTable({
name: 'workflows_tags', // table name for the junction table of this relation
joinColumn: {
@ -77,13 +77,10 @@ export class WorkflowEntity extends AbstractEntity implements IWorkflowDb {
})
tags?: TagEntity[];
@OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.workflow)
@OneToMany('SharedWorkflow', 'workflow')
shared: SharedWorkflow[];
@OneToMany(
() => WorkflowStatistics,
(workflowStatistics: WorkflowStatistics) => workflowStatistics.workflow,
)
@OneToMany('WorkflowStatistics', 'workflow')
@JoinColumn({ referencedColumnName: 'workflow' })
statistics: WorkflowStatistics[];

View File

@ -1,6 +1,6 @@
import { Column, Entity, RelationId, ManyToOne, PrimaryColumn } from 'typeorm';
import { datetimeColumnType } from './AbstractEntity';
import { WorkflowEntity } from './WorkflowEntity';
import type { WorkflowEntity } from './WorkflowEntity';
export enum StatisticsNames {
productionSuccess = 'production_success',
@ -20,7 +20,7 @@ export class WorkflowStatistics {
@PrimaryColumn({ length: 128 })
name: StatisticsNames;
@ManyToOne(() => WorkflowEntity, (workflow) => workflow.shared, {
@ManyToOne('WorkflowEntity', 'shared', {
primary: true,
onDelete: 'CASCADE',
})

View File

@ -1,7 +1,8 @@
import { INode, IRun, IWorkflowBase, LoggerProxy } from 'n8n-workflow';
import { Db, InternalHooksManager } from '..';
import { StatisticsNames } from '../databases/entities/WorkflowStatistics';
import { getWorkflowOwner } from '../UserManagement/UserManagementHelper';
import * as Db from '@/Db';
import { InternalHooksManager } from '@/InternalHooksManager';
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
export async function workflowExecutionCompleted(
workflowData: IWorkflowBase,

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

@ -8,7 +8,6 @@ import { FindOperator, In, IsNull, LessThanOrEqual, Not, Raw } from 'typeorm';
import * as ActiveExecutions from '@/ActiveExecutions';
import config from '@/config';
import { User } from '@/databases/entities/User';
import { DEFAULT_EXECUTIONS_GET_ALL_LIMIT } from '@/GenericHelpers';
import {
IExecutionFlattedResponse,
IExecutionResponse,
@ -22,7 +21,9 @@ import type { ExecutionRequest } from '@/requests';
import * as ResponseHelper from '@/ResponseHelper';
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
import { WorkflowRunner } from '@/WorkflowRunner';
import { DatabaseType, Db, GenericHelpers } from '..';
import type { DatabaseType } from '@/Interfaces';
import * as Db from '@/Db';
import * as GenericHelpers from '@/GenericHelpers';
interface IGetExecutionsQueryFilter {
id?: FindOperator<string>;
@ -174,7 +175,7 @@ export class ExecutionsService {
const limit = req.query.limit
? parseInt(req.query.limit, 10)
: DEFAULT_EXECUTIONS_GET_ALL_LIMIT;
: GenericHelpers.DEFAULT_EXECUTIONS_GET_ALL_LIMIT;
const executingWorkflowIds: string[] = [];

View File

@ -1,5 +1,6 @@
import { getLicense } from '@/License';
import { Db, ILicenseReadResponse } from '..';
import type { ILicenseReadResponse } from '@/Interfaces';
import * as Db from '@/Db';
export class LicenseService {
static async getActiveTriggerCount(): Promise<number> {

View File

@ -4,12 +4,9 @@ import express from 'express';
import { LoggerProxy } from 'n8n-workflow';
import { getLogger } from '@/Logger';
import {
ILicensePostResponse,
ILicenseReadResponse,
InternalHooksManager,
ResponseHelper,
} from '..';
import * as ResponseHelper from '@/ResponseHelper';
import { InternalHooksManager } from '@/InternalHooksManager';
import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces';
import { LicenseService } from './License.service';
import { getLicense } from '@/License';
import { AuthenticatedRequest, LicenseRequest } from '@/requests';

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

@ -10,7 +10,7 @@ import { validateEntity } from '@/GenericHelpers';
import type { WorkflowRequest } from '@/requests';
import { isSharingEnabled, rightDiff } from '@/UserManagement/UserManagementHelper';
import { EEWorkflowsService as EEWorkflows } from './workflows.services.ee';
import { externalHooks } from '../Server';
import { ExternalHooks } from '@/ExternalHooks';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { LoggerProxy } from 'n8n-workflow';
import * as TagHelpers from '@/TagHelpers';
@ -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,
);
}),
);
@ -81,10 +87,12 @@ EEWorkflowController.get(
ResponseHelper.send(async (req: WorkflowRequest.Get) => {
const { id: workflowId } = req.params;
const workflow = await EEWorkflows.get(
{ id: parseInt(workflowId, 10) },
{ relations: ['shared', 'shared.user', 'shared.role'] },
);
const relations = ['shared', 'shared.user', 'shared.role'];
if (!config.getEnv('workflowTagsDisabled')) {
relations.push('tags');
}
const workflow = await EEWorkflows.get({ id: parseInt(workflowId, 10) }, { relations });
if (!workflow) {
throw new ResponseHelper.NotFoundError(`Workflow with ID "${workflowId}" does not exist`);
@ -94,13 +102,13 @@ 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',
);
}
EEWorkflows.addOwnerAndSharings(workflow);
await EEWorkflows.addCredentialsToWorkflow(workflow, req.user);
return workflow;
return { ...workflow, id: workflow.id.toString() };
}),
);
@ -117,7 +125,7 @@ EEWorkflowController.post(
await validateEntity(newWorkflow);
await externalHooks.run('workflow.create', [newWorkflow]);
await ExternalHooks().run('workflow.create', [newWorkflow]);
const { tags: tagIds } = req.body;
@ -178,7 +186,7 @@ EEWorkflowController.post(
});
}
await externalHooks.run('workflow.afterCreate', [savedWorkflow]);
await ExternalHooks().run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false);
const { id, ...rest } = savedWorkflow;
@ -206,7 +214,7 @@ EEWorkflowController.get(
EEWorkflows.addOwnerAndSharings(workflow);
await EEWorkflows.addCredentialsToWorkflow(workflow, req.user);
workflow.nodes = [];
return workflow;
return { ...workflow, id: workflow.id.toString() };
}),
);
}),

View File

@ -17,7 +17,7 @@ import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { validateEntity } from '@/GenericHelpers';
import { InternalHooksManager } from '@/InternalHooksManager';
import { externalHooks } from '@/Server';
import { ExternalHooks } from '@/ExternalHooks';
import { getLogger } from '@/Logger';
import type { WorkflowRequest } from '@/requests';
import { isBelowOnboardingThreshold } from '@/WorkflowHelpers';
@ -57,7 +57,7 @@ workflowsController.post(
await validateEntity(newWorkflow);
await externalHooks.run('workflow.create', [newWorkflow]);
await ExternalHooks().run('workflow.create', [newWorkflow]);
const { tags: tagIds } = req.body;
@ -103,7 +103,7 @@ workflowsController.post(
});
}
await externalHooks.run('workflow.afterCreate', [savedWorkflow]);
await ExternalHooks().run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false);
const { id, ...rest } = savedWorkflow;
@ -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,
});
@ -273,7 +273,7 @@ workflowsController.delete(
ResponseHelper.send(async (req: WorkflowRequest.Delete) => {
const { id: workflowId } = req.params;
await externalHooks.run('workflow.delete', [workflowId]);
await ExternalHooks().run('workflow.delete', [workflowId]);
const shared = await Db.collections.SharedWorkflow.findOne({
relations: ['workflow', 'role'],
@ -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,
});
@ -303,7 +303,7 @@ workflowsController.delete(
await Db.collections.Workflow.delete(workflowId);
void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, workflowId, false);
await externalHooks.run('workflow.afterDelete', [workflowId]);
await ExternalHooks().run('workflow.afterDelete', [workflowId]);
return true;
}),

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

@ -13,7 +13,7 @@ import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { User } from '@db/entities/User';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { validateEntity } from '@/GenericHelpers';
import { externalHooks } from '@/Server';
import { ExternalHooks } from '@/ExternalHooks';
import * as TagHelpers from '@/TagHelpers';
import { WorkflowRequest } from '@/requests';
import { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces';
@ -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,
});
@ -246,7 +252,7 @@ export class WorkflowsService {
WorkflowHelpers.addNodeIds(workflow);
await externalHooks.run('workflow.update', [workflow]);
await ExternalHooks().run('workflow.update', [workflow]);
if (shared.workflow.active) {
// When workflow gets saved always remove it as the triggers could have been
@ -332,13 +338,13 @@ export class WorkflowsService {
});
}
await externalHooks.run('workflow.afterUpdate', [updatedWorkflow]);
await ExternalHooks().run('workflow.afterUpdate', [updatedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowSaved(user.id, updatedWorkflow, false);
if (updatedWorkflow.active) {
// When the workflow is supposed to be active add it again
try {
await externalHooks.run('workflow.activate', [updatedWorkflow]);
await ExternalHooks().run('workflow.activate', [updatedWorkflow]);
await ActiveWorkflowRunner.getInstance().add(
workflowId,
shared.workflow.active ? 'update' : 'activate',
@ -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

@ -249,6 +249,7 @@ function toTableName(sourceName: CollectionName | MappingName) {
Settings: 'settings',
InstalledPackages: 'installed_packages',
InstalledNodes: 'installed_nodes',
WorkflowStatistics: 'workflow_statistics',
}[sourceName];
}

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

@ -1,6 +1,6 @@
import config from '@/config';
import { InternalHooksManager } from '../../src';
import { nodeFetchedData, workflowExecutionCompleted } from '../../src/events/WorkflowStatistics';
import { InternalHooksManager } from '@/InternalHooksManager';
import { nodeFetchedData, workflowExecutionCompleted } from '@/events/WorkflowStatistics';
import { LoggerProxy, WorkflowExecuteMode } from 'n8n-workflow';
import { getLogger } from '@/Logger';
@ -10,14 +10,14 @@ const mockedFirstProductionWorkflowSuccess = jest.fn((...args) => {});
const mockedFirstWorkflowDataLoad = jest.fn((...args) => {});
jest.spyOn(InternalHooksManager, 'getInstance').mockImplementation((...args) => {
const actual = jest.requireActual('../../src/InternalHooks');
const actual = jest.requireActual('@/InternalHooks');
return {
...actual,
onFirstProductionWorkflowSuccess: mockedFirstProductionWorkflowSuccess,
onFirstWorkflowDataLoad: mockedFirstWorkflowDataLoad,
};
});
jest.mock('../../src/Db', () => {
jest.mock('@/Db', () => {
return {
collections: {
Workflow: {
@ -36,7 +36,7 @@ jest.mock('../../src/Db', () => {
},
};
});
jest.mock('../../src/UserManagement/UserManagementHelper', () => {
jest.mock('@/UserManagement/UserManagementHelper', () => {
return {
getWorkflowOwner: jest.fn((workflowId) => {
return { id: FAKE_USER_ID };

View File

@ -19,7 +19,7 @@ describe('License', () => {
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);
});
let license;
let license: License;
beforeEach(async () => {
license = new License();
@ -102,6 +102,6 @@ describe('License', () => {
jest.fn(license.getMainPlan).mockReset();
const mainPlan = license.getMainPlan();
expect(mainPlan.id).toBe(MOCK_MAIN_PLAN_ID);
expect(mainPlan?.id).toBe(MOCK_MAIN_PLAN_ID);
});
});

View File

@ -1,16 +1,16 @@
import { v4 as uuid } from 'uuid';
import { INodeTypeData, INodeTypes, Workflow } from 'n8n-workflow';
import { Db } from '../../src';
import * as Db from '@/Db';
import * as testDb from '../integration/shared/testDb';
import { NodeTypes as MockNodeTypes } from './Helpers';
import { PermissionChecker } from '../../src/UserManagement/PermissionChecker';
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import {
randomCredentialPayload as randomCred,
randomPositiveDigit,
} from '../integration/shared/random';
import type { Role } from '../../src/databases/entities/Role';
import type { Role } from '@/databases/entities/Role';
import type { SaveCredentialFunction } from '../integration/shared/types';
let testDbName = '';

View File

@ -1,13 +1,12 @@
const { sharedOptions } = require('@n8n_io/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
...sharedOptions(__dirname),
ignorePatterns: ['bin/*.js'],

View File

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "0.148.1",
"version": "0.149.0",
"description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -53,7 +53,7 @@
"form-data": "^4.0.0",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.130.0",
"n8n-workflow": "~0.131.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"pretty-bytes": "^5.6.0",

View File

@ -836,7 +836,7 @@ export async function getBinaryDataBuffer(
propertyName: string,
inputIndex: number,
): Promise<Buffer> {
const binaryData = inputData.main![inputIndex]![itemIndex]!.binary![propertyName]!;
const binaryData = inputData.main[inputIndex]![itemIndex]!.binary![propertyName]!;
return BinaryDataManager.getInstance().retrieveBinaryData(binaryData);
}

View File

@ -876,8 +876,8 @@ export class WorkflowExecute {
// The most nodes just have one but merge node for example has two and data
// of both inputs has to be available to be able to process the node.
if (
executionData.data.main!.length < connectionIndex ||
executionData.data.main![connectionIndex] === null
executionData.data.main.length < connectionIndex ||
executionData.data.main[connectionIndex] === null
) {
// Does not have the data of the connections so add back to stack
this.runExecutionData.executionData!.nodeExecutionStack.push(executionData);

View File

@ -1,14 +1,12 @@
const { sharedOptions } = require('@n8n_io/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/frontend'],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
extraFileExtensions: ['.vue'],
},
...sharedOptions(__dirname, 'frontend'),
rules: {
// TODO: Remove these

View File

@ -1,6 +1,6 @@
{
"name": "n8n-design-system",
"version": "0.48.0",
"version": "0.49.0",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {

View File

@ -1,14 +1,12 @@
const { sharedOptions } = require('@n8n_io/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/frontend'],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
extraFileExtensions: ['.vue'],
},
...sharedOptions(__dirname, 'frontend'),
ignorePatterns: ['*.d.cts'],
@ -19,6 +17,7 @@ module.exports = {
'import/no-default-export': 'off',
'import/no-extraneous-dependencies': 'off',
'import/order': 'off',
'import/no-cycle': 'warn',
indent: 'off',
'prettier/prettier': 'off',
'@typescript-eslint/ban-types': 'off',

View File

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "0.174.1",
"version": "0.175.0",
"description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -57,8 +57,8 @@
"lodash.set": "^4.3.2",
"luxon": "^2.3.0",
"monaco-editor": "^0.33.0",
"n8n-design-system": "~0.48.0",
"n8n-workflow": "~0.130.0",
"n8n-design-system": "~0.49.0",
"n8n-workflow": "~0.131.0",
"normalize-wheel": "^1.0.1",
"pinia": "^2.0.22",
"prismjs": "^1.17.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

After

Width:  |  Height:  |  Size: 363 KiB

View File

@ -41,7 +41,7 @@ import {
import { FAKE_DOOR_FEATURES } from './constants';
import { BulkCommand, Undoable } from '@/models/history';
export * from 'n8n-design-system/src/types';
export * from 'n8n-design-system/types';
declare module 'jsplumb' {
interface PaintStyle {
@ -179,6 +179,7 @@ export interface IUpdateInformation {
export interface INodeUpdatePropertiesInformation {
name: string; // Node-Name
properties: {
position: XYPosition;
[key: string]: IDataObject | XYPosition;
};
}
@ -234,8 +235,7 @@ export interface IRestApi {
deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void>;
retryExecution(id: string, loadWorkflow?: boolean): Promise<boolean>;
getTimezones(): Promise<IDataObject>;
getBinaryBufferString(dataPath: string): Promise<string>;
getBinaryUrl(dataPath: string): string;
getBinaryUrl(dataPath: string, mode: 'view' | 'download'): string;
}
export interface INodeTranslationHeaders {
@ -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

@ -111,19 +111,5 @@ export default mixins(nodeHelpers, restApi).extend({
height: 100%;
}
}
.binary-data {
background-color: var(--color-foreground-xlight);
&.image {
max-height: calc(100% - 1em);
max-width: calc(100% - 1em);
}
&.other {
height: calc(100% - 1em);
width: calc(100% - 1em);
}
}
}
</style>

View File

@ -56,7 +56,7 @@ export default mixins(restApi).extend({
}
} else {
try {
const binaryUrl = this.restApi().getBinaryUrl(id);
const binaryUrl = this.restApi().getBinaryUrl(id, 'view');
if (isJSONData) {
this.jsonData = await (await fetch(binaryUrl)).json();
} else {

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 } },
)
@ -117,7 +124,7 @@ import OauthButton from './OauthButton.vue';
import { restApi } from '@/mixins/restApi';
import { addCredentialTranslation } from '@/plugins/i18n';
import mixins from 'vue-typed-mixins';
import { BUILTIN_CREDENTIALS_DOCS_URL, EnterpriseEditionFeature } from '@/constants';
import { BUILTIN_CREDENTIALS_DOCS_URL, DOCS_DOMAIN, EnterpriseEditionFeature } from '@/constants';
import { IPermissions } from '@/permissions';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
@ -222,20 +229,29 @@ export default mixins(restApi).extend({
const activeNode = this.ndvStore.activeNode;
const isCommunityNode = activeNode ? isCommunityPackageName(activeNode.type) : false;
if (!type || !type.documentationUrl) {
const documentationUrl = type && type.documentationUrl;
if (!documentationUrl) {
return '';
}
if (
type.documentationUrl.startsWith('https://') ||
type.documentationUrl.startsWith('http://')
) {
return type.documentationUrl;
let url: URL;
if (documentationUrl.startsWith('https://') || documentationUrl.startsWith('http://')) {
url = new URL(documentationUrl);
if (url.hostname !== DOCS_DOMAIN) return documentationUrl;
} else {
// Don't show documentation link for community nodes if the URL is not an absolute path
if (isCommunityNode) return '';
else url = new URL(`${BUILTIN_CREDENTIALS_DOCS_URL}${documentationUrl}/`);
}
return isCommunityNode
? '' // Don't show documentation link for community nodes if the URL is not an absolute path
: `${BUILTIN_CREDENTIALS_DOCS_URL}${type.documentationUrl}/?utm_source=n8n_app&utm_medium=left_nav_menu&utm_campaign=create_new_credentials_modal`;
if (url.hostname === DOCS_DOMAIN) {
url.searchParams.set('utm_source', 'n8n_app');
url.searchParams.set('utm_medium', 'left_nav_menu');
url.searchParams.set('utm_campaign', 'create_new_credentials_modal');
}
return url.href;
},
isGoogleOAuthType(): boolean {
return (

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,
},
{
id: 'details',
label: this.$locale.baseText('credentialEdit.credentialEdit.details'),
position: 'top',
},
];
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

@ -273,7 +273,7 @@ import WorkflowActivator from '@/components/WorkflowActivator.vue';
import Modal from '@/components/Modal.vue';
import { externalHooks } from '@/mixins/externalHooks';
import { WAIT_TIME_UNLIMITED, EXECUTIONS_MODAL_KEY, VIEWS } from '@/constants';
import { WAIT_TIME_UNLIMITED, EXECUTIONS_MODAL_KEY, VIEWS, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import { restApi } from '@/mixins/restApi';
import { genericHelpers } from '@/mixins/genericHelpers';
@ -434,7 +434,13 @@ export default mixins(externalHooks, genericHelpers, restApi, showMessage).exten
this.modalBus.$emit('close');
},
convertToDisplayDate,
displayExecution(execution: IExecutionShortResponse, e: PointerEvent) {
displayExecution(execution: IExecutionsSummary, e: PointerEvent) {
if (!this.workflowsStore.workflowId || this.workflowsStore.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID || execution.workflowId !== this.workflowsStore.workflowId) {
const workflowExecutions: IExecutionsSummary[] = this.combinedExecutions.filter(ex => ex.workflowId === execution.workflowId);
this.workflowsStore.currentWorkflowExecutions = workflowExecutions;
this.workflowsStore.activeWorkflowExecution = execution;
}
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({
name: VIEWS.EXECUTION_PREVIEW,

View File

@ -71,6 +71,7 @@
v-for="execution in executions"
:key="execution.id"
:execution="execution"
:ref="`execution-${execution.id}`"
@refresh="onRefresh"
@retryExecution="onRetryExecution"
/>
@ -95,6 +96,7 @@ import Vue from 'vue';
import { PropType } from 'vue';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
export default Vue.extend({
name: 'executions-sidebar',
@ -127,7 +129,7 @@ export default Vue.extend({
};
},
computed: {
...mapStores(useUIStore),
...mapStores(useUIStore, useWorkflowsStore),
statusFilterApplied(): boolean {
return this.filter.status !== '';
},
@ -153,6 +155,7 @@ export default Vue.extend({
if (this.autoRefresh) {
this.autoRefreshInterval = setInterval(() => this.onRefresh(), 4000);
}
this.scrollToActiveCard();
},
beforeDestroy() {
if (this.autoRefreshInterval) {
@ -206,6 +209,18 @@ export default Vue.extend({
status: this.filter.status,
};
},
scrollToActiveCard(): void {
const executionsList = this.$refs.executionList as HTMLElement;
const currentExecutionCard = this.$refs[`execution-${this.workflowsStore.activeWorkflowExecution?.id}`] as Vue[];
if (executionsList && currentExecutionCard && this.workflowsStore.activeWorkflowExecution) {
const cardElement = currentExecutionCard[0].$el as HTMLElement;
const cardRect = cardElement.getBoundingClientRect();
if (cardRect.top > executionsList.offsetHeight) {
executionsList.scrollTo({ top: cardRect.top });
}
}
},
},
});
</script>

View File

@ -174,7 +174,13 @@ export default mixins(
const shouldUpdate = workflowUpdated && !onNewWorkflow;
await this.initView(shouldUpdate);
if (!shouldUpdate) {
await this.setExecutions();
if (this.workflowsStore.currentWorkflowExecutions.length > 0) {
const workflowExecutions = await this.loadExecutions();
this.workflowsStore.addToCurrentExecutions(workflowExecutions);
this.setActiveExecution();
} else {
await this.setExecutions();
}
}
this.loading = false;
},
@ -186,7 +192,9 @@ export default mixins(
}
await this.openWorkflow(this.$route.params.name);
this.uiStore.nodeViewInitialized = false;
this.setExecutions();
if(this.workflowsStore.currentWorkflowExecutions.length === 0) {
this.setExecutions();
}
if (this.activeExecution) {
this.$router
.push({

View File

@ -47,6 +47,7 @@
:value="value"
:isReadOnly="isReadOnly"
@change="valueChanged"
@close="closeDialog"
ref="inputFieldExpression"
data-test-id="expression-modal-input"
/>

View File

@ -1,5 +1,5 @@
<template>
<div ref="root" class="ph-no-capture"></div>
<div ref="root" class="ph-no-capture" @keydown.stop @keydown.esc="onClose"></div>
</template>
<script lang="ts">
@ -82,6 +82,9 @@ export default mixins(expressionManager, workflowHelpers).extend({
this.editor?.destroy();
},
methods: {
onClose() {
this.$emit('close');
},
itemSelected({ variable }: IVariableItemSelected) {
if (!this.editor || this.isReadOnly) return;

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

@ -492,7 +492,7 @@ export default mixins(
computed: {
...mapStores(useCredentialsStore, useNodeTypesStore, useNDVStore, useWorkflowsStore),
expressionDisplayValue(): string {
if (this.activeDrop || this.forceShowExpression) {
if (this.forceShowExpression) {
return '';
}

View File

@ -1202,7 +1202,7 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten
const { id, data, fileName, fileExtension, mimeType } = this.binaryData[index][key];
if (id) {
const url = this.restApi().getBinaryUrl(id);
const url = this.restApi().getBinaryUrl(id, 'download');
saveAs(url, [fileName, fileExtension].join('.'));
return;
} else {

View File

@ -229,7 +229,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
const updateInformation: INodeUpdatePropertiesInformation = {
name: this.node.name,
properties: {
position: { position },
position,
},
};

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

@ -57,21 +57,22 @@ export const BREAKPOINT_LG = 1200;
export const BREAKPOINT_XL = 1920;
export const N8N_IO_BASE_URL = `https://api.n8n.io/api/`;
export const BUILTIN_NODES_DOCS_URL = `https://docs.n8n.io/integrations/builtin/`;
export const BUILTIN_CREDENTIALS_DOCS_URL = `https://docs.n8n.io/integrations/builtin/credentials/`;
export const DATA_PINNING_DOCS_URL = 'https://docs.n8n.io/data/data-pinning/';
export const DATA_EDITING_DOCS_URL = 'https://docs.n8n.io/data/data-editing/';
export const DOCS_DOMAIN = 'docs.n8n.io';
export const BUILTIN_NODES_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/builtin/`;
export const BUILTIN_CREDENTIALS_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/builtin/credentials/`;
export const DATA_PINNING_DOCS_URL = `https://${DOCS_DOMAIN}/data/data-pinning/`;
export const DATA_EDITING_DOCS_URL = `https://${DOCS_DOMAIN}/data/data-editing/`;
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = `https://api.npms.io/v2/`;
export const NPM_PACKAGE_DOCS_BASE_URL = `https://www.npmjs.com/package/`;
export const NPM_KEYWORD_SEARCH_URL = `https://www.npmjs.com/search?q=keywords%3An8n-community-node-package`;
export const N8N_QUEUE_MODE_DOCS_URL = `https://docs.n8n.io/hosting/scaling/queue-mode/`;
export const COMMUNITY_NODES_INSTALLATION_DOCS_URL = `https://docs.n8n.io/integrations/community-nodes/installation/`;
export const N8N_QUEUE_MODE_DOCS_URL = `https://${DOCS_DOMAIN}/hosting/scaling/queue-mode/`;
export const COMMUNITY_NODES_INSTALLATION_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/community-nodes/installation/`;
export const COMMUNITY_NODES_NPM_INSTALLATION_URL =
'https://docs.npmjs.com/downloading-and-installing-node-js-and-npm';
export const COMMUNITY_NODES_RISKS_DOCS_URL = `https://docs.n8n.io/integrations/community-nodes/risks/`;
export const COMMUNITY_NODES_BLOCKLIST_DOCS_URL = `https://docs.n8n.io/integrations/community-nodes/blocklist/`;
export const CUSTOM_NODES_DOCS_URL = `https://docs.n8n.io/integrations/creating-nodes/code/create-n8n-nodes-module/`;
export const EXPRESSIONS_DOCS_URL = 'https://docs.n8n.io/code-examples/expressions/';
export const COMMUNITY_NODES_RISKS_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/community-nodes/risks/`;
export const COMMUNITY_NODES_BLOCKLIST_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/community-nodes/blocklist/`;
export const CUSTOM_NODES_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/creating-nodes/code/create-n8n-nodes-module/`;
export const EXPRESSIONS_DOCS_URL = `https://${DOCS_DOMAIN}/code-examples/expressions/`;
// node types
export const BAMBOO_HR_NODE_TYPE = 'n8n-nodes-base.bambooHr';
@ -318,8 +319,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 +373,6 @@ export enum WORKFLOW_MENU_ACTIONS {
*/
export enum EnterpriseEditionFeature {
Sharing = 'sharing',
WorkflowSharing = 'workflowSharing',
}
export const MAIN_NODE_PANEL_WIDTH = 360;

View File

@ -119,14 +119,12 @@ export const expressionManager = mixins(workflowHelpers).extend({
* _part_ of the result, but displayed when they are the _entire_ result.
*
* Example:
* - Expression `This is a {{ null }} test` is displayed as `This is a test`.
* - Expression `{{ null }}` is displayed as `[Object: null]`.
* - Expression `This is a {{ [] }} test` is displayed as `This is a test`.
* - Expression `{{ [] }}` is displayed as `[Array: []]`.
*
* Conditionally displayed segments:
* - `[Object: null]`
* - `[Array: []]`
* - `[empty]` (from `''`, not from `undefined`)
* - `null` (from `NaN`)
*
* Exceptionally, for two segments, display differs based on context:
* - Date is displayed as
@ -157,9 +155,8 @@ export const expressionManager = mixins(workflowHelpers).extend({
this.segments.length > 1 &&
s.kind === 'resolvable' &&
typeof s.resolved === 'string' &&
(['[Object: null]', '[Array: []]'].includes(s.resolved) ||
s.resolved === this.$locale.baseText('expressionModalInput.empty') ||
s.resolved === this.$locale.baseText('expressionModalInput.null'))
(s.resolved === '[Array: []]' ||
s.resolved === this.$locale.baseText('expressionModalInput.empty'))
) {
return false;
}

View File

@ -1,4 +1,4 @@
import { IExecutionResponse, IExecutionsCurrentSummaryExtended, IPushData } from '../../Interface';
import { IExecutionResponse, IExecutionsCurrentSummaryExtended, IPushData } from '@/Interface';
import { externalHooks } from '@/mixins/externalHooks';
import { nodeHelpers } from '@/mixins/nodeHelpers';

View File

@ -201,13 +201,8 @@ export const restApi = Vue.extend({
},
// Binary data
getBinaryBufferString: (dataPath: string): Promise<string> => {
return self.restApi().makeRestApiRequest('GET', `/data/${dataPath}`);
},
getBinaryUrl: (dataPath: string): string => {
return self.rootStore.getRestApiContext.baseUrl + `/data/${dataPath}`;
},
getBinaryUrl: (dataPath, mode): string =>
self.rootStore.getRestApiContext.baseUrl + `/data/${dataPath}?mode=${mode}`,
};
},
},

View File

@ -1,6 +1,6 @@
import Vue from 'vue';
import { WorkflowTitleStatus } from '../../Interface';
import { WorkflowTitleStatus } from '@/Interface';
export const titleChange = Vue.extend({
methods: {

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

@ -1,5 +1,5 @@
import Vue from 'vue';
import 'n8n-design-system/src/shims-element-ui';
import 'n8n-design-system/shims-element-ui';
declare module '*.vue' {
import Vue from 'vue';

View File

@ -1,4 +1,4 @@
import startCase from 'lodash.startCase';
import { startCase } from 'lodash';
import { defineStore } from 'pinia';
import {
INodePropertyCollection,

Some files were not shown because too many files have changed in this diff Show More