feat: Make it possible to dynamically load community nodes (#2849)

*  Make it possible to dynamically load node packages

*  Fix comment

*  Make possible to dynamically install nodes from npm

* Created migration for sqlite regarding community nodes

* Saving to db whenever a package with nodes is installed

* Created endpoint to fetch installed packages

* WIP - uninstall package with nodes

* Fix lint issues

* Updating nodes via API

* Lint and improvement fixes

* Created community node helpers and removed packages taht do not contain nodes

* Check for package updates when fetching installed packages

* Blocked access to non-owner and preventing incorrect install of packages

* Added auto healing process

* Unit tests for helpers

* Finishing tests for helpers

* Improved unit tests, refactored more helpers and created integration tests for GET

* Implemented detection of missing packages on init and added warning to frontend settings

* Add check for banned packages and fix broken tests

* Create migrations for other db systems

* Updated with latest changes from master

* Fixed conflict errors

* Improved unit tests, refactored more helpers and created integration tests for GET

* Implemented detection of missing packages on init and added warning to frontend settings

* 🔥 Removing access check for the Settings sidebar item

*  Added inital community nodes settings screen

* Added executionMode flag to settings

*  Implemented N8N-callout component

* 💄Updating Callout component template propery names

* 💄 Updating Callout component styling.

* 💄Updating Callout component sizing and colors.

* ✔️ Updating Callout component test snapshots after styling changes

*  Updating the `ActionBox` component so it supports callouts and conditional button rendering

* 💄 Removing duplicate callout theme validation in the `ActionBox` component. Adding a selection control for it in the storybook.

*  Added warning message if instance is in the queue mode. Updated colors based on the new design.

*  Added a custom permission support to router

* 🔨 Implemented UM detection as a custom permission.

* 👌Updating route permission logic.

*  Implemented installed community packages list in the settings view

* 👌 Updating settings routes rules and community nodes setting view.

* Allow installation of packages that failed to load

* 👌 Updating `ActionBox`, `CommuntyPackageCard` components and settings loading logic.

* 👌 Fixing community nodes loading state and sidebar icon spacing.

*  Implemented loading skeletons for community package cards

* 👌 Handling errrors while loading installed package list. Updating spacing.

* 👌 Updating community nodes error messages.

* Added disable flag

* 🐛 Fixing a community nodes update detection bug when there are missing packages. (#3497)

*  Added front-end support for community nodes feature flag

*  Implemented community package installation modal dialog

* 💄 Community nodes installation modal updates: Moved links to constants and used them in translations, disabling inputs in loading state.

*  Implemented community packages install flow

* Standardize error codes (#3501)

* Standardize error: 400 for request issues such as invalid package name and 500 for installation problems

* Fix http status code for when package is not found

*  Implemented community package installation modal dialog

* 💄 Community nodes installation modal updates: Moved links to constants and used them in translations, disabling inputs in loading state.

*  Implemented community packages install flow

*  Updated error handling based on the response codes

*  Implemented community package installation modal dialog

*  Implemented community package uninstall flow.

*  Finished update confirm modal UI

* 💄 Replaced community nodes tooltip image with the one exported from figma.

*  Implemented community package update process

*  Updating community nodes list after successful package update

* 🔒 Updating public API setting route to use new access rules. Updating express app definition in community nodes tests

*  Implemented community package installation modal dialog

* 💄 Community nodes installation modal updates: Moved links to constants and used them in translations, disabling inputs in loading state.

*  Implemented community packages install flow

*  Updated error handling based on the response codes

* Change output for installation request

* Improve payload for update requests

* 👌 Updating community nodes install modal UI

* 👌 Updating community nodes confirm modal logic

* 👌 Refactoring community nodes confirm modal dialog

* 👌 Separating community nodes components loading states

* 💄 Updating community nodes install modal spacing.

* Fix behavior for installing already installed packages

* 💡 Commenting community nodes install process

* 🔥 Removing leftover commits of deleted Vue mutations

*  Updated node list to identify community nodes and handle node name clash

*  Implemented missing community node dialog.

* 💄 Updating n8n-tabs component to support tooltips

*  Updating node details with community node details.

* 🔨 Using back-end response when updating community packages

* 👌 Updating tabs component and refactoring community nodes store mutations

* 👌 Adding community node flag to node type descriptions and using it to identify community nodes

* 👌 Hiding unnecessary elements from missing node details panel.

* 👌 Updating missing node type descriptions for custom and community nodes

* 👌 Updating community node package name detection logic

* 👌 Removing communityNode flag from node description

*  Adding `force` flag to credentials fetching (#3527)

*  Adding `force` flag to credentials fetching which can be used to skip check when loading credentials

*  Forcing credentials loading when opening nodeView

* 👌 Minor updates to community nodes details panel

* tests for post endpoint

* duplicate comments

* Add Patch and Delete enpoints tests

* 🔒 Using `pageCategory`prop to assemble the list of settings routes instead of hard-coded array (#3562)

* 📈 Added front-end telemetry events for community nodes

* 📈 Updating community nodes telemetry events

* 💄 Updating community nodes settings UI elements based on product/design review

* 💄 Updating node view & node details view for community nodes based on product/design feedback

* 💄 Fixing community node text capitalisation

*  Adding community node install error message under the package name input field

* Fixed and improved tests

* Fix lint issue

* feat: Migrated to npm release of riot-tmpl fork.

* 📈 Updating community nodes telemetry events based on the product review

* 💄 Updating community nodes UI based on the design feedback

* 🔀 Merging recent node draggable panels changes

* Implement self healing process

* Improve error messages for package name requirement and disk space

* 💄 Removing front-end error message override since appropriate response is available from the back-end

* Fix lint issues

* Fix installed node name

* 💄 Removed additional node name parsing

* 📈 Updating community nodes telemetry events

* Fix postgres migration for cascading nodes when package is removed

* Remove postman mock for banned packages

* 📈 Adding missing telemetry event for community node documentation click

* 🐛 Fixing community nodes UI bugs reported during the bug bash

* Fix issue with uninstalling packages not reflecting UI

* 🐛 Fixing a missing node type bug when trying to run a workflow.

* Improve error detection for installing packages

* 💄 Updating community nodes components styling and wording based on the product feedback

* Implement telemetry be events

* Add author name and email to packages

* Fix telemetry be events for community packages

* 📈 Updating front-end telemetry events with community nodes author data

* 💄 Updating credentials documentation link logic to handle community nodes credentials

* 🐛 Fixing draggable panels logic

* Fix duplicate wrong import

* 💄 Hiding community nodes credentials documentation links when they don't contain an absolute URL

* Fix issue with detection of missing packages

* 💄 Adding the `Docs` tab to community nodes

* 💄 Adding a failed loading indicator to community nodes list

* Prevent n8n from crashing on startup

* Refactor and improve code quality

*  Remove not needed depenedency

Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: Milorad Filipović <milorad@n8n.io>
Co-authored-by: Milorad FIlipović <miloradfilipovic19@gmail.com>
Co-authored-by: agobrech <ael.gobrecht@gmail.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
Jan Oberhauser 2022-07-20 16:24:03 +02:00 committed by GitHub
parent a02b206170
commit c85faff4f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 3951 additions and 166 deletions

View file

@ -33,6 +33,7 @@ import {
} from '../src';
import { getLogger } from '../src/Logger';
import { getAllInstalledPackages } from '../src/CommunityNodes/packageModel';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires
const open = require('open');
@ -60,6 +61,10 @@ export class Start extends Command {
description:
'runs the webhooks via a hooks.n8n.cloud tunnel server. Use only for testing and development!',
}),
reinstallMissingPackages: flags.boolean({
description:
'Attempts to self heal n8n if packages with nodes are missing. Might drastically increase startup times.',
}),
};
/**
@ -206,6 +211,23 @@ export class Start extends Command {
// Wait till the database is ready
await startDbInitPromise;
const installedPackages = await getAllInstalledPackages();
const missingPackages = new Set<{
packageName: string;
version: string;
}>();
installedPackages.forEach((installedpackage) => {
installedpackage.installedNodes.forEach((installedNode) => {
if (!loadNodesAndCredentials.nodeTypes[installedNode.type]) {
// Leave the list ready for installing in case we need.
missingPackages.add({
packageName: installedpackage.packageName,
version: installedpackage.installedVersion,
});
}
});
});
await UserSettings.getEncryptionKey();
// Load settings from database and set them to config.
@ -214,6 +236,42 @@ export class Start extends Command {
config.set(setting.key, JSON.parse(setting.value));
});
config.set('nodes.packagesMissing', '');
if (missingPackages.size) {
LoggerProxy.error(
'n8n detected that some packages are missing. For more information, visit https://docs.n8n.io/integrations/community-nodes/troubleshooting/',
);
if (flags.reinstallMissingPackages || process.env.N8N_REINSTALL_MISSING_PACKAGES) {
LoggerProxy.info('Attempting to reinstall missing packages', { missingPackages });
try {
// Optimistic approach - stop if any installation fails
// eslint-disable-next-line no-restricted-syntax
for (const missingPackage of missingPackages) {
// eslint-disable-next-line no-await-in-loop
void (await loadNodesAndCredentials.loadNpmModule(
missingPackage.packageName,
missingPackage.version,
));
missingPackages.delete(missingPackage);
}
LoggerProxy.info(
'Packages reinstalled successfully. Resuming regular intiailization.',
);
} catch (error) {
LoggerProxy.error('n8n was unable to install the missing packages.');
}
}
}
if (missingPackages.size) {
config.set(
'nodes.packagesMissing',
Array.from(missingPackages)
.map((missingPackage) => `${missingPackage.packageName}@${missingPackage.version}`)
.join(' '),
);
}
if (config.getEnv('executions.mode') === 'queue') {
const redisHost = config.getEnv('queue.bull.redis.host');
const redisPassword = config.getEnv('queue.bull.redis.password');

View file

@ -740,6 +740,14 @@ export const schema = {
default: 'n8n-nodes-base.errorTrigger',
env: 'NODES_ERROR_TRIGGER_TYPE',
},
communityPackages: {
enabled: {
doc: 'Allows you to disable the usage of community packages for nodes',
format: Boolean,
default: true,
env: 'N8N_COMMUNITY_PACKAGES_ENABLED',
},
},
},
logs: {

View file

@ -86,7 +86,6 @@
"@types/supertest": "^2.0.11",
"@types/uuid": "^8.3.0",
"@types/validator": "^13.7.0",
"axios": "^0.21.1",
"concurrently": "^5.1.0",
"jest": "^27.4.7",
"nodemon": "^2.0.2",
@ -109,6 +108,7 @@
"@types/shelljs": "^0.8.11",
"@types/swagger-ui-express": "^4.1.3",
"@types/yamljs": "^0.2.31",
"axios": "^0.21.1",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"body-parser": "^1.18.3",

View file

@ -0,0 +1,218 @@
/* eslint-disable import/no-cycle */
/* eslint-disable @typescript-eslint/naming-convention */
import { promisify } from 'util';
import { exec } from 'child_process';
import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises';
import { UserSettings } from 'n8n-core';
import { LoggerProxy, PublicInstalledPackage } from 'n8n-workflow';
import axios from 'axios';
import {
NODE_PACKAGE_PREFIX,
NPM_COMMAND_TOKENS,
NPM_PACKAGE_STATUS_GOOD,
RESPONSE_ERROR_MESSAGES,
} from '../constants';
import { NpmPackageStatusCheck, NpmUpdatesAvailable, ParsedNpmPackageName } from '../Interfaces';
import { InstalledPackages } from '../databases/entities/InstalledPackages';
import config from '../../config';
const execAsync = promisify(exec);
export const parsePackageName = (originalString: string | undefined): ParsedNpmPackageName => {
if (!originalString) {
throw new Error('Package name was not provided');
}
if (new RegExp(/[^0-9a-z@\-./]/).test(originalString)) {
// Prevent any strings that are not valid npm package names or
// could indicate malicous commands
throw new Error('Package name must be a single word');
}
const scope = originalString.includes('/') ? originalString.split('/')[0] : undefined;
const packageNameWithoutScope = scope ? originalString.replace(`${scope}/`, '') : originalString;
if (!packageNameWithoutScope.startsWith(NODE_PACKAGE_PREFIX)) {
throw new Error('Package name must start with n8n-nodes-');
}
const version = packageNameWithoutScope.includes('@')
? packageNameWithoutScope.split('@')[1]
: undefined;
const packageName = version ? originalString.replace(`@${version}`, '') : originalString;
return {
packageName,
scope,
version,
originalString,
};
};
export const executeCommand = async (
command: string,
options?: {
doNotHandleError?: boolean;
},
): Promise<string> => {
const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath();
// Make sure the node-download folder exists
try {
await fsAccess(downloadFolder);
// eslint-disable-next-line no-empty
} catch (error) {
await fsMkdir(downloadFolder);
}
const execOptions = {
cwd: downloadFolder,
env: {
NODE_PATH: process.env.NODE_PATH,
PATH: process.env.PATH,
},
};
try {
const commandResult = await execAsync(command, execOptions);
return commandResult.stdout;
} catch (error) {
if (options?.doNotHandleError) {
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const errorMessage = error.message as string;
if (
errorMessage.includes(NPM_COMMAND_TOKENS.NPM_PACKAGE_NOT_FOUND_ERROR) ||
errorMessage.includes(NPM_COMMAND_TOKENS.NPM_NO_VERSION_AVAILABLE)
) {
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND);
}
if (errorMessage.includes(NPM_COMMAND_TOKENS.NPM_PACKAGE_VERSION_NOT_FOUND_ERROR)) {
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_VERSION_NOT_FOUND);
}
if (
errorMessage.includes(NPM_COMMAND_TOKENS.NPM_DISK_NO_SPACE) ||
errorMessage.includes(NPM_COMMAND_TOKENS.NPM_DISK_INSUFFICIENT_SPACE)
) {
throw new Error(RESPONSE_ERROR_MESSAGES.DISK_IS_FULL);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
LoggerProxy.warn('npm command failed; see message', { errorMessage });
throw new Error('Package could not be installed - check logs for details');
}
};
export function matchPackagesWithUpdates(
installedPackages: InstalledPackages[],
availableUpdates?: NpmUpdatesAvailable,
): PublicInstalledPackage[] {
if (!availableUpdates) {
return installedPackages;
}
const hydratedPackageList = [] as PublicInstalledPackage[];
for (let i = 0; i < installedPackages.length; i++) {
const installedPackage = installedPackages[i];
const publicPackage = { ...installedPackage } as PublicInstalledPackage;
if (availableUpdates[installedPackage.packageName]) {
publicPackage.updateAvailable = availableUpdates[installedPackage.packageName].latest;
}
hydratedPackageList.push(publicPackage);
}
return hydratedPackageList;
}
export function matchMissingPackages(
installedPackages: PublicInstalledPackage[],
missingPackages: string,
): PublicInstalledPackage[] {
const missingPackageNames = missingPackages.split(' ');
const missingPackagesList = missingPackageNames.map((missingPackageName: string) => {
// Strip away versions but maintain scope and package name
try {
const parsedPackageData = parsePackageName(missingPackageName);
return parsedPackageData.packageName;
// eslint-disable-next-line no-empty
} catch (_) {}
return undefined;
});
const hydratedPackageList = [] as PublicInstalledPackage[];
installedPackages.forEach((installedPackage) => {
const hydratedInstalledPackage = { ...installedPackage };
if (missingPackagesList.includes(hydratedInstalledPackage.packageName)) {
hydratedInstalledPackage.failedLoading = true;
}
hydratedPackageList.push(hydratedInstalledPackage);
});
return hydratedPackageList;
}
export async function checkPackageStatus(packageName: string): Promise<NpmPackageStatusCheck> {
// You can change this URL for testing - the default testing url below
// is a postman mock service
const n8nBackendServiceUrl = 'https://api.n8n.io/api/package';
try {
const output = await axios.post(
n8nBackendServiceUrl,
{ name: packageName },
{
method: 'POST',
},
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (output.data.status !== NPM_PACKAGE_STATUS_GOOD) {
return output.data as NpmPackageStatusCheck;
}
} catch (error) {
// Do nothing if service is unreachable
}
return { status: NPM_PACKAGE_STATUS_GOOD };
}
export function hasPackageLoadedSuccessfully(packageName: string): boolean {
try {
const failedPackages = (config.get('nodes.packagesMissing') as string).split(' ');
const packageFailedToLoad = failedPackages.find(
(packageNameAndVersion) =>
packageNameAndVersion.startsWith(packageName) &&
packageNameAndVersion.replace(packageName, '').startsWith('@'),
);
if (packageFailedToLoad) {
return false;
}
return true;
} catch (_error) {
// If key doesn't exist it means all packages loaded fine
return true;
}
}
export function removePackageFromMissingList(packageName: string): void {
try {
const failedPackages = (config.get('nodes.packagesMissing') as string).split(' ');
const packageFailedToLoad = failedPackages.filter(
(packageNameAndVersion) =>
!packageNameAndVersion.startsWith(packageName) ||
!packageNameAndVersion.replace(packageName, '').startsWith('@'),
);
config.set('nodes.packagesMissing', packageFailedToLoad.join(' '));
} catch (_error) {
// Do nothing
}
}

View file

@ -0,0 +1,75 @@
/* eslint-disable import/no-cycle */
import { INodeTypeData, INodeTypeNameVersion, LoggerProxy } from 'n8n-workflow';
import { Db } from '..';
import { InstalledNodes } from '../databases/entities/InstalledNodes';
import { InstalledPackages } from '../databases/entities/InstalledPackages';
export async function searchInstalledPackage(
packageName: string,
): Promise<InstalledPackages | undefined> {
const installedPackage = await Db.collections.InstalledPackages.findOne(packageName, {
relations: ['installedNodes'],
});
return installedPackage;
}
export async function getAllInstalledPackages(): Promise<InstalledPackages[]> {
const installedPackages = await Db.collections.InstalledPackages.find({
relations: ['installedNodes'],
});
return installedPackages;
}
export async function removePackageFromDatabase(packageName: InstalledPackages): Promise<void> {
void (await Db.collections.InstalledPackages.remove(packageName));
}
export async function persistInstalledPackageData(
installedPackageName: string,
installedPackageVersion: string,
installedNodes: INodeTypeNameVersion[],
loadedNodeTypes: INodeTypeData,
authorName?: string,
authorEmail?: string,
): Promise<InstalledPackages> {
let installedPackage: InstalledPackages;
try {
await Db.transaction(async (transactionManager) => {
const promises = [];
const installedPackagePayload = Object.assign(new InstalledPackages(), {
packageName: installedPackageName,
installedVersion: installedPackageVersion,
authorName,
authorEmail,
});
installedPackage = await transactionManager.save<InstalledPackages>(installedPackagePayload);
installedPackage.installedNodes = [];
promises.push(
...installedNodes.map(async (loadedNode) => {
const installedNodePayload = Object.assign(new InstalledNodes(), {
name: loadedNodeTypes[loadedNode.name].type.description.displayName,
type: loadedNode.name,
latestVersion: loadedNode.version,
package: installedPackageName,
});
installedPackage.installedNodes.push(installedNodePayload);
return transactionManager.save<InstalledNodes>(installedNodePayload);
}),
);
return promises;
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return installedPackage!;
} catch (error) {
LoggerProxy.error('Failed to save installed packages and nodes', {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
error,
packageName: installedPackageName,
});
throw error;
}
}

View file

@ -194,6 +194,8 @@ export async function init(
collections.SharedCredentials = linkRepository(entities.SharedCredentials);
collections.SharedWorkflow = linkRepository(entities.SharedWorkflow);
collections.Settings = linkRepository(entities.Settings);
collections.InstalledPackages = linkRepository(entities.InstalledPackages);
collections.InstalledNodes = linkRepository(entities.InstalledNodes);
isInitialized = true;

View file

@ -35,6 +35,8 @@ import { User } from './databases/entities/User';
import { SharedCredentials } from './databases/entities/SharedCredentials';
import { SharedWorkflow } from './databases/entities/SharedWorkflow';
import { Settings } from './databases/entities/Settings';
import { InstalledPackages } from './databases/entities/InstalledPackages';
import { InstalledNodes } from './databases/entities/InstalledNodes';
export interface IActivationError {
time: number;
@ -83,6 +85,8 @@ export interface IDatabaseCollections {
SharedCredentials: Repository<SharedCredentials>;
SharedWorkflow: Repository<SharedWorkflow>;
Settings: Repository<Settings>;
InstalledPackages: Repository<InstalledPackages>;
InstalledNodes: Repository<InstalledNodes>;
}
export interface IWebhookDb {
@ -461,6 +465,19 @@ export interface IVersionNotificationSettings {
infoUrl: string;
}
export interface IN8nNodePackageJson {
name: string;
version: string;
n8n?: {
credentials?: string[];
nodes?: string[];
};
author?: {
name?: string;
email?: string;
};
}
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
@ -494,6 +511,9 @@ export interface IN8nUISettings {
enabled: boolean;
host: string;
};
missingPackages?: boolean;
executionMode: 'regular' | 'queue';
communityNodesEnabled: boolean;
}
export interface IPersonalizationSurveyAnswers {
@ -532,6 +552,8 @@ export type IPushData =
| PushDataExecuteAfter
| PushDataExecuteBefore
| PushDataConsoleMessage
| PushDataReloadNodeType
| PushDataRemoveNodeType
| PushDataTestWebhook;
type PushDataExecutionFinished = {
@ -559,6 +581,16 @@ type PushDataConsoleMessage = {
type: 'sendConsoleMessage';
};
type PushDataReloadNodeType = {
data: IPushDataReloadNodeType;
type: 'reloadNodeType';
};
type PushDataRemoveNodeType = {
data: IPushDataRemoveNodeType;
type: 'removeNodeType';
};
type PushDataTestWebhook = {
data: IPushDataTestWebhook;
type: 'testWebhookDeleted' | 'testWebhookReceived';
@ -590,6 +622,16 @@ export interface IPushDataNodeExecuteBefore {
nodeName: string;
}
export interface IPushDataReloadNodeType {
name: string;
version: number;
}
export interface IPushDataRemoveNodeType {
name: string;
version: number;
}
export interface IPushDataTestWebhook {
executionId: string;
workflowId: string;
@ -669,6 +711,31 @@ export interface IWorkflowExecuteProcess {
export type WhereClause = Record<string, { id: string }>;
/** ********************************
* Commuinity nodes
******************************** */
export type ParsedNpmPackageName = {
packageName: string;
originalString: string;
scope?: string;
version?: string;
};
export type NpmUpdatesAvailable = {
[packageName: string]: {
current: string;
wanted: string;
latest: string;
location: string;
};
};
export type NpmPackageStatusCheck = {
status: 'OK' | 'Banned';
reason?: string;
};
// ----------------------------------
// telemetry
// ----------------------------------

View file

@ -386,4 +386,45 @@ export class InternalHooksClass implements IInternalHooksClass {
failedEmailData,
);
}
/**
* Community nodes backend telemetry events
*/
async onCommunityPackageInstallFinished(installationData: {
user_id: string;
input_string: string;
package_name: string;
success: boolean;
package_version?: string;
package_node_names?: string[];
package_author?: string;
package_author_email?: string;
failure_reason?: string;
}): Promise<void> {
return this.telemetry.track('cnr package install finished', installationData);
}
async onCommunityPackageUpdateFinished(updateData: {
user_id: string;
package_name: string;
package_version_current: string;
package_version_new: string;
package_node_names: string[];
package_author?: string;
package_author_email?: string;
}): Promise<void> {
return this.telemetry.track('cnr package updated', updateData);
}
async onCommunityPackageDeleteFinished(updateData: {
user_id: string;
package_name: string;
package_version: string;
package_node_names: string[];
package_author?: string;
package_author_email?: string;
}): Promise<void> {
return this.telemetry.track('cnr package deleted', updateData);
}
}

View file

@ -1,3 +1,5 @@
/* eslint-disable import/no-cycle */
/* eslint-disable no-underscore-dangle */
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable no-prototype-builtins */
/* eslint-disable no-param-reassign */
@ -16,6 +18,7 @@ import {
ILogger,
INodeType,
INodeTypeData,
INodeTypeNameVersion,
INodeVersionedType,
LoggerProxy,
} from 'n8n-workflow';
@ -28,8 +31,18 @@ import {
} from 'fs/promises';
import glob from 'fast-glob';
import path from 'path';
import { IN8nNodePackageJson } from './Interfaces';
import { getLogger } from './Logger';
import config from '../config';
import { NodeTypes } from '.';
import { InstalledPackages } from './databases/entities/InstalledPackages';
import { InstalledNodes } from './databases/entities/InstalledNodes';
import { executeCommand } from './CommunityNodes/helpers';
import { RESPONSE_ERROR_MESSAGES } from './constants';
import {
persistInstalledPackageData,
removePackageFromDatabase,
} from './CommunityNodes/packageModel';
const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
@ -50,6 +63,29 @@ class LoadNodesAndCredentialsClass {
this.logger = getLogger();
LoggerProxy.init(this.logger);
// Make sure the imported modules can resolve dependencies fine.
process.env.NODE_PATH = module.paths.join(':');
// @ts-ignore
module.constructor._initPaths();
this.nodeModulesPath = await this.getNodeModulesFolderLocation();
this.excludeNodes = config.getEnv('nodes.exclude');
this.includeNodes = config.getEnv('nodes.include');
// Get all the installed packages which contain n8n nodes
const nodePackages = await this.getN8nNodePackages(this.nodeModulesPath);
for (const packagePath of nodePackages) {
await this.loadDataFromPackage(packagePath);
}
await this.loadNodesFromDownloadedPackages();
await this.loadNodesFromCustomFolders();
}
async getNodeModulesFolderLocation(): Promise<string> {
// Get the path to the node-modules folder to be later able
// to load the credentials and nodes
const checkPaths = [
@ -63,29 +99,37 @@ class LoadNodesAndCredentialsClass {
try {
await fsAccess(checkPath);
// Folder exists, so use it.
this.nodeModulesPath = path.dirname(checkPath);
break;
return path.dirname(checkPath);
} catch (error) {
// Folder does not exist so get next one
// eslint-disable-next-line no-continue
continue;
}
}
throw new Error('Could not find "node_modules" folder!');
}
if (this.nodeModulesPath === '') {
throw new Error('Could not find "node_modules" folder!');
}
this.excludeNodes = config.getEnv('nodes.exclude');
this.includeNodes = config.getEnv('nodes.include');
// Get all the installed packages which contain n8n nodes
const packages = await this.getN8nNodePackages();
for (const packageName of packages) {
await this.loadDataFromPackage(packageName);
async loadNodesFromDownloadedPackages(): Promise<void> {
const nodePackages = [];
try {
// Read downloaded nodes and credentials
const downloadedNodesFolder = UserSettings.getUserN8nFolderDowloadedNodesPath();
const downloadedNodesFolderModules = path.join(downloadedNodesFolder, 'node_modules');
await fsAccess(downloadedNodesFolderModules);
const downloadedPackages = await this.getN8nNodePackages(downloadedNodesFolderModules);
nodePackages.push(...downloadedPackages);
// eslint-disable-next-line no-empty
} catch (error) {}
for (const packagePath of nodePackages) {
try {
await this.loadDataFromPackage(packagePath);
// eslint-disable-next-line no-empty
} catch (error) {}
}
}
async loadNodesFromCustomFolders(): Promise<void> {
// Read nodes and credentials from custom directories
const customDirectories = [];
@ -112,10 +156,10 @@ class LoadNodesAndCredentialsClass {
* @returns {Promise<string[]>}
* @memberof LoadNodesAndCredentialsClass
*/
async getN8nNodePackages(): Promise<string[]> {
async getN8nNodePackages(baseModulesPath: string): Promise<string[]> {
const getN8nNodePackagesRecursive = async (relativePath: string): Promise<string[]> => {
const results: string[] = [];
const nodeModulesPath = `${this.nodeModulesPath}/${relativePath}`;
const nodeModulesPath = `${baseModulesPath}/${relativePath}`;
for (const file of await fsReaddir(nodeModulesPath)) {
const isN8nNodesPackage = file.indexOf('n8n-nodes-') === 0;
const isNpmScopedPackage = file.indexOf('@') === 0;
@ -126,7 +170,7 @@ class LoadNodesAndCredentialsClass {
continue;
}
if (isN8nNodesPackage) {
results.push(`${relativePath}${file}`);
results.push(`${baseModulesPath}/${relativePath}${file}`);
}
if (isNpmScopedPackage) {
results.push(...(await getN8nNodePackagesRecursive(`${relativePath}${file}/`)));
@ -188,6 +232,115 @@ class LoadNodesAndCredentialsClass {
};
}
async loadNpmModule(packageName: string, version?: string): Promise<InstalledPackages> {
const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath();
const command = `npm install ${packageName}${version ? `@${version}` : ''}`;
await executeCommand(command);
const finalNodeUnpackedPath = path.join(downloadFolder, 'node_modules', packageName);
const loadedNodes = await this.loadDataFromPackage(finalNodeUnpackedPath);
if (loadedNodes.length > 0) {
const packageFile = await this.readPackageJson(finalNodeUnpackedPath);
// Save info to DB
try {
const installedPackage = await persistInstalledPackageData(
packageFile.name,
packageFile.version,
loadedNodes,
this.nodeTypes,
packageFile.author?.name,
packageFile.author?.email,
);
this.attachNodesToNodeTypes(installedPackage.installedNodes);
return installedPackage;
} catch (error) {
LoggerProxy.error('Failed to save installed packages and nodes', { error, packageName });
throw error;
}
} else {
// Remove this package since it contains no loadable nodes
const removeCommand = `npm remove ${packageName}`;
try {
await executeCommand(removeCommand);
} catch (error) {
// Do nothing
}
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES);
}
}
async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise<void> {
const command = `npm remove ${packageName}`;
await executeCommand(command);
void (await removePackageFromDatabase(installedPackage));
this.unloadNodes(installedPackage.installedNodes);
}
async updateNpmModule(
packageName: string,
installedPackage: InstalledPackages,
): Promise<InstalledPackages> {
const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath();
const command = `npm update ${packageName}`;
try {
await executeCommand(command);
} catch (error) {
if (error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) {
throw new Error(`The npm package "${packageName}" could not be found.`);
}
throw error;
}
this.unloadNodes(installedPackage.installedNodes);
const finalNodeUnpackedPath = path.join(downloadFolder, 'node_modules', packageName);
const loadedNodes = await this.loadDataFromPackage(finalNodeUnpackedPath);
if (loadedNodes.length > 0) {
const packageFile = await this.readPackageJson(finalNodeUnpackedPath);
// Save info to DB
try {
await removePackageFromDatabase(installedPackage);
const newlyInstalledPackage = await persistInstalledPackageData(
packageFile.name,
packageFile.version,
loadedNodes,
this.nodeTypes,
packageFile.author?.name,
packageFile.author?.email,
);
this.attachNodesToNodeTypes(newlyInstalledPackage.installedNodes);
return newlyInstalledPackage;
} catch (error) {
LoggerProxy.error('Failed to save installed packages and nodes', { error, packageName });
throw error;
}
} else {
// Remove this package since it contains no loadable nodes
const removeCommand = `npm remove ${packageName}`;
try {
await executeCommand(removeCommand);
} catch (error) {
// Do nothing
}
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES);
}
}
/**
* Loads a node from a file
*
@ -196,19 +349,23 @@ class LoadNodesAndCredentialsClass {
* @param {string} filePath The file to read node from
* @returns {Promise<void>}
*/
async loadNodeFromFile(packageName: string, nodeName: string, filePath: string): Promise<void> {
async loadNodeFromFile(
packageName: string,
nodeName: string,
filePath: string,
): Promise<INodeTypeNameVersion | undefined> {
let tempNode: INodeType | INodeVersionedType;
let fullNodeName: string;
// eslint-disable-next-line import/no-dynamic-require, global-require, @typescript-eslint/no-var-requires
const tempModule = require(filePath);
let nodeVersion = 1;
try {
// eslint-disable-next-line import/no-dynamic-require, global-require, @typescript-eslint/no-var-requires
const tempModule = require(filePath);
tempNode = new tempModule[nodeName]();
this.addCodex({ node: tempNode, filePath, isCustom: packageName === 'CUSTOM' });
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error loading node "${nodeName}" from: "${filePath}"`);
// eslint-disable-next-line no-console, @typescript-eslint/restrict-template-expressions
console.error(`Error loading node "${nodeName}" from: "${filePath}" - ${error.message}`);
throw error;
}
@ -234,6 +391,7 @@ class LoadNodesAndCredentialsClass {
if (tempNode.hasOwnProperty('nodeVersions')) {
const versionedNodeType = (tempNode as INodeVersionedType).getNodeType();
this.addCodex({ node: versionedNodeType, filePath, isCustom: packageName === 'CUSTOM' });
nodeVersion = (tempNode as INodeVersionedType).currentVersion;
if (
versionedNodeType.description.icon !== undefined &&
@ -252,6 +410,12 @@ class LoadNodesAndCredentialsClass {
{ filePath },
);
}
} else {
// Short renaming to avoid type issues
const tmpNode = tempNode as INodeType;
nodeVersion = Array.isArray(tmpNode.description.version)
? tmpNode.description.version.slice(-1)[0]
: tmpNode.description.version;
}
if (this.includeNodes !== undefined && !this.includeNodes.includes(fullNodeName)) {
@ -267,6 +431,12 @@ class LoadNodesAndCredentialsClass {
type: tempNode,
sourcePath: filePath,
};
// eslint-disable-next-line consistent-return
return {
name: fullNodeName,
version: nodeVersion,
} as INodeTypeNameVersion;
}
/**
@ -341,7 +511,8 @@ class LoadNodesAndCredentialsClass {
let fileName: string;
let type: string;
const loadPromises = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadPromises: any[] = [];
for (const filePath of files) {
[fileName, type] = path.parse(filePath).name.split('.');
@ -355,26 +526,33 @@ class LoadNodesAndCredentialsClass {
await Promise.all(loadPromises);
}
async readPackageJson(packagePath: string): Promise<IN8nNodePackageJson> {
// Get the absolute path of the package
const packageFileString = await fsReadFile(path.join(packagePath, 'package.json'), 'utf8');
return JSON.parse(packageFileString) as IN8nNodePackageJson;
}
/**
* Loads nodes and credentials from the package with the given name
*
* @param {string} packageName The name to read data from
* @param {string} packagePath The path to read data from
* @returns {Promise<void>}
*/
async loadDataFromPackage(packageName: string): Promise<void> {
async loadDataFromPackage(packagePath: string): Promise<INodeTypeNameVersion[]> {
// Get the absolute path of the package
const packagePath = path.join(this.nodeModulesPath, packageName);
// Read the data from the package.json file to see if any n8n data is defiend
const packageFileString = await fsReadFile(path.join(packagePath, 'package.json'), 'utf8');
const packageFile = JSON.parse(packageFileString);
if (!packageFile.hasOwnProperty('n8n')) {
return;
const packageFile = await this.readPackageJson(packagePath);
// if (!packageFile.hasOwnProperty('n8n')) {
if (!packageFile.n8n) {
return [];
}
const packageName = packageFile.name;
let tempPath: string;
let filePath: string;
const returnData: INodeTypeNameVersion[] = [];
// Read all node types
let fileName: string;
let type: string;
@ -382,7 +560,10 @@ class LoadNodesAndCredentialsClass {
for (filePath of packageFile.n8n.nodes) {
tempPath = path.join(packagePath, filePath);
[fileName, type] = path.parse(filePath).name.split('.');
await this.loadNodeFromFile(packageName, fileName, tempPath);
const loadData = await this.loadNodeFromFile(packageName, fileName, tempPath);
if (loadData) {
returnData.push(loadData);
}
}
}
@ -399,6 +580,27 @@ class LoadNodesAndCredentialsClass {
this.loadCredentialsFromFile(fileName, tempPath);
}
}
return returnData;
}
unloadNodes(installedNodes: InstalledNodes[]): void {
const nodeTypes = NodeTypes();
installedNodes.forEach((installedNode) => {
nodeTypes.removeNodeType(installedNode.type);
delete this.nodeTypes[installedNode.type];
});
}
attachNodesToNodeTypes(installedNodes: InstalledNodes[]): void {
const nodeTypes = NodeTypes();
installedNodes.forEach((installedNode) => {
nodeTypes.attachNodeType(
installedNode.type,
this.nodeTypes[installedNode.type].type,
this.nodeTypes[installedNode.type].sourcePath,
);
});
}
}

View file

@ -57,6 +57,21 @@ class NodeTypesClass implements INodeTypes {
}
return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version);
}
attachNodeType(
nodeTypeName: string,
nodeType: INodeType | INodeVersionedType,
sourcePath: string,
): void {
this.nodeTypes[nodeTypeName] = {
type: nodeType,
sourcePath,
};
}
removeNodeType(nodeType: string): void {
delete this.nodeTypes[nodeType];
}
}
let nodeTypesInstance: NodeTypesClass | undefined;

View file

@ -90,6 +90,7 @@ import querystring from 'querystring';
import promClient, { Registry } from 'prom-client';
import * as Queue from './Queue';
import {
LoadNodesAndCredentials,
ActiveExecutions,
ActiveWorkflowRunner,
CredentialsHelper,
@ -159,6 +160,7 @@ import { ExecutionEntity } from './databases/entities/ExecutionEntity';
import { SharedWorkflow } from './databases/entities/SharedWorkflow';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants';
import { credentialsController } from './api/credentials.api';
import { nodesController } from './api/nodes.api';
import { oauth2CredentialController } from './api/oauth2Credential.api';
import {
getInstanceBaseUrl,
@ -329,6 +331,8 @@ class App {
enabled: config.getEnv('templates.enabled'),
host: config.getEnv('templates.host'),
},
executionMode: config.getEnv('executions.mode'),
communityNodesEnabled: config.getEnv('nodes.communityPackages.enabled'),
};
}
@ -355,6 +359,10 @@ class App {
config.getEnv('userManagement.skipInstanceOwnerSetup') === false,
});
if (config.get('nodes.packagesMissing').length > 0) {
this.frontendSettings.missingPackages = true;
}
return this.frontendSettings;
}
@ -706,6 +714,13 @@ class App {
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);
// ----------------------------------------
// Packages and nodes management
// ----------------------------------------
if (config.getEnv('nodes.communityPackages.enabled')) {
this.app.use(`/${this.restEndpoint}/nodes`, nodesController);
}
// ----------------------------------------
// Healthcheck
// ----------------------------------------

View file

@ -0,0 +1,316 @@
/* eslint-disable import/no-cycle */
import express = require('express');
import { LoggerProxy, PublicInstalledPackage } from 'n8n-workflow';
import { getLogger } from '../Logger';
import { ResponseHelper, LoadNodesAndCredentials, Push, InternalHooksManager } from '..';
import { NodeRequest } from '../requests';
import { RESPONSE_ERROR_MESSAGES } from '../constants';
import {
matchMissingPackages,
matchPackagesWithUpdates,
executeCommand,
checkPackageStatus,
hasPackageLoadedSuccessfully,
removePackageFromMissingList,
parsePackageName,
} from '../CommunityNodes/helpers';
import { getAllInstalledPackages, searchInstalledPackage } from '../CommunityNodes/packageModel';
import { isAuthenticatedRequest } from '../UserManagement/UserManagementHelper';
import config = require('../../config');
import { NpmUpdatesAvailable } from '../Interfaces';
export const nodesController = express.Router();
/**
* Initialize Logger if needed
*/
nodesController.use((req, res, next) => {
try {
LoggerProxy.getInstance();
} catch (error) {
LoggerProxy.init(getLogger());
}
next();
});
nodesController.use((req, res, next) => {
if (!isAuthenticatedRequest(req) || req.user.globalRole.name !== 'owner') {
res.status(403).json({ status: 'error', message: 'Unauthorized' });
return;
}
next();
});
nodesController.use((req, res, next) => {
if (config.getEnv('executions.mode') === 'queue' && req.method !== 'GET') {
res.status(400).json({
status: 'error',
message: 'Package management is disabled when running in "queue" mode',
});
return;
}
next();
});
nodesController.post(
'/',
ResponseHelper.send(async (req: NodeRequest.Post) => {
const { name } = req.body;
let parsedPackageName;
try {
parsedPackageName = parsePackageName(name);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
throw new ResponseHelper.ResponseError(error.message, undefined, 400);
}
// Only install packages that haven't been installed
// or that have failed loading
const installedPackageInstalled = await searchInstalledPackage(parsedPackageName.packageName);
const loadedPackage = hasPackageLoadedSuccessfully(name);
if (installedPackageInstalled && loadedPackage) {
throw new ResponseHelper.ResponseError(
`Package "${parsedPackageName.packageName}" is already installed. For updating, click the corresponding button.`,
undefined,
400,
);
}
const packageStatus = await checkPackageStatus(name);
if (packageStatus.status !== 'OK') {
throw new ResponseHelper.ResponseError(
`Package "${name}" has been banned from n8n's repository and will not be installed`,
undefined,
400,
);
}
try {
const installedPackage = await LoadNodesAndCredentials().loadNpmModule(
parsedPackageName.packageName,
parsedPackageName.version,
);
if (!loadedPackage) {
removePackageFromMissingList(name);
}
// Inform the connected frontends that new nodes are available
installedPackage.installedNodes.forEach((nodeData) => {
const pushInstance = Push.getInstance();
pushInstance.send('reloadNodeType', {
name: nodeData.name,
version: nodeData.latestVersion,
});
});
void InternalHooksManager.getInstance().onCommunityPackageInstallFinished({
user_id: req.user.id,
input_string: name,
package_name: parsedPackageName.packageName,
success: true,
package_version: parsedPackageName.version,
package_node_names: installedPackage.installedNodes.map((nodeData) => nodeData.name),
package_author: installedPackage.authorName,
package_author_email: installedPackage.authorEmail,
});
return installedPackage;
} catch (error) {
let statusCode = 500;
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const errorMessage = error.message as string;
if (
errorMessage.includes(RESPONSE_ERROR_MESSAGES.PACKAGE_VERSION_NOT_FOUND) ||
errorMessage.includes(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES) ||
errorMessage.includes(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND)
) {
statusCode = 400;
}
void InternalHooksManager.getInstance().onCommunityPackageInstallFinished({
user_id: req.user.id,
input_string: name,
package_name: parsedPackageName.packageName,
success: false,
package_version: parsedPackageName.version,
failure_reason: errorMessage,
});
throw new ResponseHelper.ResponseError(
`Error loading package "${name}": ${errorMessage}`,
undefined,
statusCode,
);
}
}),
);
// Install new credentials/nodes from npm
nodesController.get(
'/',
ResponseHelper.send(async (): Promise<PublicInstalledPackage[]> => {
const packages = await getAllInstalledPackages();
if (packages.length === 0) {
return packages;
}
let pendingUpdates: NpmUpdatesAvailable | undefined;
try {
// Command succeeds when there are no updates.
// NPM handles this oddly. It exits with code 1 when there are updates.
// More here: https://github.com/npm/rfcs/issues/473
await executeCommand('npm outdated --json', { doNotHandleError: true });
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
if (error.code === 1) {
// Updates available
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
pendingUpdates = JSON.parse(error.stdout);
}
}
let hydratedPackages = matchPackagesWithUpdates(packages, pendingUpdates);
try {
if (config.get('nodes.packagesMissing')) {
// eslint-disable-next-line prettier/prettier
hydratedPackages = matchMissingPackages(hydratedPackages, config.get('nodes.packagesMissing'));
}
} catch (error) {
// Do nothing if setting is missing
}
return hydratedPackages;
}),
);
// Uninstall credentials/nodes from npm
nodesController.delete(
'/',
ResponseHelper.send(async (req: NodeRequest.Delete) => {
const { name } = req.body;
if (!name) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.PACKAGE_NAME_NOT_PROVIDED,
undefined,
400,
);
}
// This function also sanitizes the package name by throwing errors.
parsePackageName(name);
const installedPackage = await searchInstalledPackage(name);
if (!installedPackage) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_INSTALLED,
undefined,
400,
);
}
try {
void (await LoadNodesAndCredentials().removeNpmModule(name, installedPackage));
// Inform the connected frontends that the node list has been updated
installedPackage.installedNodes.forEach((installedNode) => {
const pushInstance = Push.getInstance();
pushInstance.send('removeNodeType', {
name: installedNode.type,
version: installedNode.latestVersion,
});
});
void InternalHooksManager.getInstance().onCommunityPackageDeleteFinished({
user_id: req.user.id,
package_name: name,
package_version: installedPackage.installedVersion,
package_node_names: installedPackage.installedNodes.map((nodeData) => nodeData.name),
package_author: installedPackage.authorName,
package_author_email: installedPackage.authorEmail,
});
} catch (error) {
throw new ResponseHelper.ResponseError(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
`Error removing package "${name}": ${error.message}`,
undefined,
500,
);
}
}),
);
// Update a package
nodesController.patch(
'/',
ResponseHelper.send(async (req: NodeRequest.Update) => {
const { name } = req.body;
if (!name) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.PACKAGE_NAME_NOT_PROVIDED,
undefined,
400,
);
}
const parsedPackageData = parsePackageName(name);
const packagePreviouslyInstalled = await searchInstalledPackage(name);
if (!packagePreviouslyInstalled) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_INSTALLED,
undefined,
400,
);
}
try {
const newInstalledPackage = await LoadNodesAndCredentials().updateNpmModule(
parsedPackageData.packageName,
packagePreviouslyInstalled,
);
const pushInstance = Push.getInstance();
// Inform the connected frontends that new nodes are available
packagePreviouslyInstalled.installedNodes.forEach((installedNode) => {
pushInstance.send('removeNodeType', {
name: installedNode.type,
version: installedNode.latestVersion,
});
});
newInstalledPackage.installedNodes.forEach((nodeData) => {
pushInstance.send('reloadNodeType', {
name: nodeData.name,
version: nodeData.latestVersion,
});
});
void InternalHooksManager.getInstance().onCommunityPackageUpdateFinished({
user_id: req.user.id,
package_name: name,
package_version_current: packagePreviouslyInstalled.installedVersion,
package_version_new: newInstalledPackage.installedVersion,
package_node_names: newInstalledPackage.installedNodes.map((node) => node.name),
package_author: newInstalledPackage.authorName,
package_author_email: newInstalledPackage.authorEmail,
});
return newInstalledPackage;
} catch (error) {
packagePreviouslyInstalled.installedNodes.forEach((installedNode) => {
const pushInstance = Push.getInstance();
pushInstance.send('removeNodeType', {
name: installedNode.type,
version: installedNode.latestVersion,
});
});
throw new ResponseHelper.ResponseError(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
`Error updating package "${name}": ${error.message}`,
undefined,
500,
);
}
}),
);

View file

@ -4,9 +4,28 @@
import { RESPONSE_ERROR_MESSAGES as CORE_RESPONSE_ERROR_MESSAGES } from 'n8n-core';
export const NODE_PACKAGE_PREFIX = 'n8n-nodes-';
export const RESPONSE_ERROR_MESSAGES = {
NO_CREDENTIAL: 'Credential not found',
NO_ENCRYPTION_KEY: CORE_RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
PACKAGE_NAME_NOT_PROVIDED: 'Package name is required',
PACKAGE_NAME_NOT_VALID: `Package name is not valid - it must start with "${NODE_PACKAGE_PREFIX}"`,
PACKAGE_NOT_INSTALLED: 'This package is not installed - you must install it first',
PACKAGE_NOT_FOUND: 'Package not found in npm',
PACKAGE_VERSION_NOT_FOUND: 'The specified package version was not found',
PACKAGE_DOES_NOT_CONTAIN_NODES: 'The specified package does not contain any nodes',
DISK_IS_FULL: 'There appears to be insufficient disk space',
};
export const AUTH_COOKIE_NAME = 'n8n-auth';
export const NPM_COMMAND_TOKENS = {
NPM_PACKAGE_NOT_FOUND_ERROR: '404 Not Found',
NPM_PACKAGE_VERSION_NOT_FOUND_ERROR: 'No matching version found for',
NPM_NO_VERSION_AVAILABLE: 'No valid versions available',
NPM_DISK_NO_SPACE: 'ENOSPC',
NPM_DISK_INSUFFICIENT_SPACE: 'insufficient space',
};
export const NPM_PACKAGE_STATUS_GOOD = 'OK';

View file

@ -0,0 +1,22 @@
/* eslint-disable import/no-cycle */
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { InstalledPackages } from './InstalledPackages';
@Entity()
export class InstalledNodes {
@Column()
name: string;
@PrimaryColumn()
type: string;
@Column()
latestVersion: string;
@ManyToOne(
() => InstalledPackages,
(installedPackages: InstalledPackages) => installedPackages.installedNodes,
)
@JoinColumn({ name: 'package', referencedColumnName: 'packageName' })
package: InstalledPackages;
}

View file

@ -0,0 +1,67 @@
/* eslint-disable import/no-cycle */
import {
BeforeUpdate,
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToMany,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { IsDate, IsOptional } from 'class-validator';
import config = require('../../../config');
import { DatabaseType } from '../../index';
import { InstalledNodes } from './InstalledNodes';
function getTimestampSyntax() {
const dbType = config.get('database.type') as DatabaseType;
const map: { [key in DatabaseType]: string } = {
sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')",
postgresdb: 'CURRENT_TIMESTAMP(3)',
mysqldb: 'CURRENT_TIMESTAMP(3)',
mariadb: 'CURRENT_TIMESTAMP(3)',
};
return map[dbType];
}
@Entity()
export class InstalledPackages {
@PrimaryColumn()
packageName: string;
@Column()
installedVersion: string;
@Column()
authorName?: string;
@Column()
authorEmail?: string;
@OneToMany(() => InstalledNodes, (installedNode) => installedNode.package)
@JoinColumn({ referencedColumnName: 'package' })
installedNodes: InstalledNodes[];
@CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() })
@IsOptional() // ignored by validation because set at DB level
@IsDate()
createdAt: Date;
@UpdateDateColumn({
precision: 3,
default: () => getTimestampSyntax(),
onUpdate: getTimestampSyntax(),
})
@IsOptional() // ignored by validation because set at DB level
@IsDate()
updatedAt: Date;
@BeforeUpdate()
setUpdateDate(): void {
this.updatedAt = new Date();
}
}

View file

@ -10,6 +10,8 @@ import { Role } from './Role';
import { Settings } from './Settings';
import { SharedWorkflow } from './SharedWorkflow';
import { SharedCredentials } from './SharedCredentials';
import { InstalledPackages } from './InstalledPackages';
import { InstalledNodes } from './InstalledNodes';
export const entities = {
CredentialsEntity,
@ -22,4 +24,6 @@ export const entities = {
Settings,
SharedWorkflow,
SharedCredentials,
InstalledPackages,
InstalledNodes,
};

View file

@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
export class CommunityNodes1652254514003 implements MigrationInterface {
name = 'CommunityNodes1652254514003';
public async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(
`CREATE TABLE \`${tablePrefix}installed_packages\` (` +
'`packageName` char(214) NOT NULL,' +
'`installedVersion` char(50) NOT NULL,' +
'`authorName` char(70) NULL,' +
'`authorEmail` char(70) NULL,' +
'`createdAt` datetime NULL DEFAULT CURRENT_TIMESTAMP,' +
'`updatedAt` datetime NULL DEFAULT CURRENT_TIMESTAMP,' +
'PRIMARY KEY (\`packageName\`)' +
') ENGINE=InnoDB;'
);
await queryRunner.query(
`CREATE TABLE \`${tablePrefix}installed_nodes\` (` +
'`name` char(200) NOT NULL,' +
'`type` char(200) NOT NULL,' +
"`latestVersion` int NOT NULL DEFAULT '1'," +
'`package` char(214) NOT NULL,' +
'PRIMARY KEY (`name`),' +
`INDEX \`FK_${tablePrefix}73f857fc5dce682cef8a99c11dbddbc969618951\` (\`package\` ASC)` +
") ENGINE='InnoDB';"
);
await queryRunner.query(
`ALTER TABLE \`${tablePrefix}installed_nodes\` ADD CONSTRAINT \`FK_${tablePrefix}73f857fc5dce682cef8a99c11dbddbc969618951\` FOREIGN KEY (\`package\`) REFERENCES \`${tablePrefix}installed_packages\`(\`packageName\`) ON DELETE CASCADE ON UPDATE CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(
`ALTER TABLE ${tablePrefix}workflow_entity ADD UNIQUE INDEX \`IDX_${tablePrefix}943d8f922be094eb507cb9a7f9\` (\`name\`)`,
);
await queryRunner.query(`DROP TABLE "${tablePrefix}installed_nodes"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}installed_packages"`);
}
}

View file

@ -14,6 +14,7 @@ import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecu
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
import { CommunityNodes1652254514003 } from './1652254514003-CommunityNodes';
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
export const mysqlMigrations = [
@ -33,5 +34,6 @@ export const mysqlMigrations = [
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
CommunityNodes1652254514003,
AddAPIKeyColumn1652905585850,
];

View file

@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import {
logMigrationEnd,
logMigrationStart,
} from '../../utils/migrationHelpers';
export class CommunityNodes1652254514002 implements MigrationInterface {
name = 'CommunityNodes1652254514002';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
let tablePrefix = config.getEnv('database.tablePrefix');
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(
`CREATE TABLE ${tablePrefix}installed_packages (` +
'"packageName" VARCHAR(214) NOT NULL,' +
'"installedVersion" VARCHAR(50) NOT NULL,' +
'"authorName" VARCHAR(70) NULL,' +
'"authorEmail" VARCHAR(70) NULL,' +
'"createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,' +
'"updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,' +
`CONSTRAINT "PK_${tablePrefix}08cc9197c39b028c1e9beca225940576fd1a5804" PRIMARY KEY ("packageName")` +
');',
);
await queryRunner.query(
`CREATE TABLE ${tablePrefix}installed_nodes (` +
'"name" VARCHAR(200) NOT NULL, ' +
'"type" VARCHAR(200) NOT NULL, ' +
'"latestVersion" integer NOT NULL DEFAULT 1, ' +
'"package" VARCHAR(241) NOT NULL, ' +
`CONSTRAINT "PK_${tablePrefix}8ebd28194e4f792f96b5933423fc439df97d9689" PRIMARY KEY ("name"), ` +
`CONSTRAINT "FK_${tablePrefix}73f857fc5dce682cef8a99c11dbddbc969618951" FOREIGN KEY ("package") REFERENCES ${tablePrefix}installed_packages ("packageName") ON DELETE CASCADE ON UPDATE CASCADE ` +
');'
);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query(`DROP TABLE "${tablePrefix}installed_nodes"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}installed_packages"`);
}
}

View file

@ -12,6 +12,7 @@ import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseT
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
import { CommunityNodes1652254514002 } from './1652254514002-CommunityNodes';
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
export const postgresMigrations = [
@ -29,5 +30,6 @@ export const postgresMigrations = [
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
CommunityNodes1652254514002,
AddAPIKeyColumn1652905585850,
];

View file

@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import {
logMigrationEnd,
logMigrationStart,
} from '../../utils/migrationHelpers';
export class CommunityNodes1652254514001 implements MigrationInterface {
name = 'CommunityNodes1652254514001';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query(
`CREATE TABLE "${tablePrefix}installed_packages" (` +
`"packageName" char(214) NOT NULL,` +
`"installedVersion" char(50) NOT NULL,` +
`"authorName" char(70) NULL,` +
`"authorEmail" char(70) NULL,` +
`"createdAt" datetime(3) NOT NULL DEFAULT 'STRFTIME(''%Y-%m-%d %H:%M:%f'', ''NOW'')',` +
`"updatedAt" datetime(3) NOT NULL DEFAULT 'STRFTIME(''%Y-%m-%d %H:%M:%f'', ''NOW'')',` +
`PRIMARY KEY("packageName")` +
`);`
);
await queryRunner.query(
`CREATE TABLE "${tablePrefix}installed_nodes" (` +
`"name" char(200) NOT NULL,` +
`"type" char(200) NOT NULL,` +
`"latestVersion" INTEGER DEFAULT 1,` +
`"package" char(214) NOT NULL,` +
`PRIMARY KEY("name"),` +
`FOREIGN KEY("package") REFERENCES "${tablePrefix}installed_packages"("packageName") ON DELETE CASCADE ON UPDATE CASCADE` +
`);`
);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query(`DROP TABLE "${tablePrefix}installed_nodes"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}installed_packages"`);
}
}

View file

@ -11,6 +11,7 @@ import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecu
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
import { CommunityNodes1652254514001 } from './1652254514001-CommunityNodes'
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
const sqliteMigrations = [
@ -27,6 +28,7 @@ const sqliteMigrations = [
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
CommunityNodes1652254514001,
AddAPIKeyColumn1652905585850,
];

View file

@ -290,3 +290,17 @@ export type NodeParameterOptionsRequest = AuthenticatedRequest<
export declare namespace TagsRequest {
type Delete = AuthenticatedRequest<{ id: string }>;
}
export declare namespace NodeRequest {
type RequestBody = {
name: string;
};
type GetAll = AuthenticatedRequest;
type Post = AuthenticatedRequest<{}, {}, RequestBody>;
type Delete = Post;
type Update = Post;
}

View file

@ -0,0 +1,341 @@
import { exec } from 'child_process';
import express from 'express';
import * as utils from './shared/utils';
import type { InstalledNodePayload, InstalledPackagePayload } from './shared/types';
import type { Role } from '../../src/databases/entities/Role';
import type { User } from '../../src/databases/entities/User';
import * as testDb from './shared/testDb';
jest.mock('../../src/CommunityNodes/helpers', () => ({
matchPackagesWithUpdates: jest.requireActual('../../src/CommunityNodes/helpers').matchPackagesWithUpdates,
parsePackageName: jest.requireActual('../../src/CommunityNodes/helpers').parsePackageName,
hasPackageLoadedSuccessfully: jest.fn(),
searchInstalledPackage: jest.fn(),
executeCommand: jest.fn(),
checkPackageStatus: jest.fn(),
removePackageFromMissingList: jest.fn(),
}));
jest.mock('../../src/CommunityNodes/packageModel', () => ({
getAllInstalledPackages: jest.requireActual('../../src/CommunityNodes/packageModel').getAllInstalledPackages,
removePackageFromDatabase: jest.fn(),
searchInstalledPackage: jest.fn(),
}));
import { executeCommand, checkPackageStatus, hasPackageLoadedSuccessfully, removePackageFromMissingList } from '../../src/CommunityNodes/helpers';
import { getAllInstalledPackages, searchInstalledPackage, removePackageFromDatabase } from '../../src/CommunityNodes/packageModel';
import { CURRENT_PACKAGE_VERSION, UPDATED_PACKAGE_VERSION } from './shared/constants';
import { installedPackagePayload } from './shared/utils';
jest.mock('../../src/telemetry');
jest.mock('../../src/LoadNodesAndCredentials', () => ({
LoadNodesAndCredentials: jest.fn(),
}));
import { LoadNodesAndCredentials } from '../../src/LoadNodesAndCredentials';
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
let globalMemberRole: Role;
let ownerShell: User;
beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['nodes'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
utils.initConfigFile();
globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole();
ownerShell = await testDb.createUserShell(globalOwnerRole);
utils.initTestLogger();
utils.initTestTelemetry();
});
beforeEach(async () => {
await testDb.truncate(['InstalledNodes', 'InstalledPackages'], testDbName);
// @ts-ignore
executeCommand.mockReset();
// @ts-ignore
checkPackageStatus.mockReset();
// @ts-ignore
searchInstalledPackage.mockReset();
// @ts-ignore
hasPackageLoadedSuccessfully.mockReset();
});
afterAll(async () => {
await testDb.terminate(testDbName);
});
test('GET /nodes should return empty list when no nodes are installed', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerAgent.get('/nodes').send();
expect(response.statusCode).toBe(200);
expect(response.body.data).toHaveLength(0);
});
test('GET /nodes should return list with installed package and node', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const installedPackage = await saveMockPackage(installedPackagePayload());
await saveMockNode(utils.installedNodePayload(installedPackage.packageName));
const response = await authOwnerAgent.get('/nodes').send();
expect(response.statusCode).toBe(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].installedNodes).toHaveLength(1);
});
test('GET /nodes should return list with multiple installed package and node', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const installedPackage1 = await saveMockPackage(installedPackagePayload());
await saveMockNode(utils.installedNodePayload(installedPackage1.packageName));
const installedPackage2 = await saveMockPackage(installedPackagePayload());
await saveMockNode(utils.installedNodePayload(installedPackage2.packageName));
await saveMockNode(utils.installedNodePayload(installedPackage2.packageName));
const response = await authOwnerAgent.get('/nodes').send();
expect(response.statusCode).toBe(200);
expect(response.body.data).toHaveLength(2);
expect([...response.body.data[0].installedNodes, ...response.body.data[1].installedNodes]).toHaveLength(3);
});
test('GET /nodes should not check for updates when there are no packages installed', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
await authOwnerAgent.get('/nodes').send();
expect(executeCommand).toHaveBeenCalledTimes(0);
});
test('GET /nodes should check for updates when there are packages installed', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const installedPackage = await saveMockPackage(installedPackagePayload());
await saveMockNode(utils.installedNodePayload(installedPackage.packageName));
await authOwnerAgent.get('/nodes').send();
expect(executeCommand).toHaveBeenCalledWith('npm outdated --json', {"doNotHandleError": true});
});
test('GET /nodes should mention updates when available', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const installedPackage = await saveMockPackage(installedPackagePayload());
await saveMockNode(utils.installedNodePayload(installedPackage.packageName));
// @ts-ignore
executeCommand.mockImplementation(() => {
throw getNpmOutdatedError(installedPackage.packageName);
});
const response = await authOwnerAgent.get('/nodes').send();
expect(response.body.data[0].installedVersion).toBe(CURRENT_PACKAGE_VERSION);
expect(response.body.data[0].updateAvailable).toBe(UPDATED_PACKAGE_VERSION);
});
// TEST POST ENDPOINT
test('POST /nodes package name should not be empty', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerAgent.post('/nodes').send();
expect(response.statusCode).toBe(400);
});
test('POST /nodes Should not install duplicate packages', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const requestBody = {
name: installedPackagePayload().packageName,
};
// @ts-ignore
searchInstalledPackage.mockImplementation(() => {
return true;
});
// @ts-ignore
hasPackageLoadedSuccessfully.mockImplementation(() => {
return true;
});
const response = await authOwnerAgent.post('/nodes').send(requestBody);
expect(response.status).toBe(400);
expect(response.body.message).toContain('already installed');
});
test('POST /nodes Should allow installing packages that could not be loaded', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const requestBody = {
name: installedPackagePayload().packageName,
};
// @ts-ignore
searchInstalledPackage.mockImplementation(() => {
return true;
});
// @ts-ignore
hasPackageLoadedSuccessfully.mockImplementation(() => {
return false;
});
// @ts-ignore
checkPackageStatus.mockImplementation(() => {
return {status:'OK'};
});
// @ts-ignore
LoadNodesAndCredentials.mockImplementation(() => {
return {
loadNpmModule: () => {
return {
installedNodes: [],
};
},
};
});
const response = await authOwnerAgent.post('/nodes').send(requestBody);
expect(removePackageFromMissingList).toHaveBeenCalled();
expect(response.status).toBe(200);
});
test('POST /nodes package should not install banned package', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const installedPackage = installedPackagePayload();
const requestBody = {
name: installedPackage.packageName,
};
// @ts-ignore
checkPackageStatus.mockImplementation(() => {
return {status:'Banned'};
});
const response = await authOwnerAgent.post('/nodes').send(requestBody);
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain('banned');
});
// TEST DELETE ENDPOINT
test('DELETE /nodes package name should not be empty', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerAgent.delete('/nodes').send();
expect(response.statusCode).toBe(400);
});
test('DELETE /nodes Should return error when package was not installed', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const requestBody = {
name: installedPackagePayload().packageName,
};
const response = await authOwnerAgent.delete('/nodes').send(requestBody);
expect(response.status).toBe(400);
expect(response.body.message).toContain('not installed');
});
// Useful test ?
test('DELETE /nodes package should be uninstall all conditions are true', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const requestBody = {
name: installedPackagePayload().packageName,
};
// @ts-ignore
searchInstalledPackage.mockImplementation(() => {
return {
installedNodes: [],
};
});
const removeNpmModuleMock = jest.fn();
// @ts-ignore
LoadNodesAndCredentials.mockImplementation(() => {
return {
removeNpmModule: removeNpmModuleMock,
};
});
const response = await authOwnerAgent.delete('/nodes').send(requestBody);
expect(response.statusCode).toBe(200);
expect(removeNpmModuleMock).toHaveBeenCalledTimes(1);
});
// TEST PATCH ENDPOINT
test('PATCH /nodes package name should not be empty', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerAgent.patch('/nodes').send();
expect(response.statusCode).toBe(400);
});
test('PATCH /nodes Should return error when package was not installed', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const requestBody = {
name: installedPackagePayload().packageName,
};
const response = await authOwnerAgent.patch('/nodes').send(requestBody);
expect(response.status).toBe(400);
expect(response.body.message).toContain('not installed');
});
test('PATCH /nodes package should be updated if all conditions are true', async () => {
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const requestBody = {
name: installedPackagePayload().packageName,
};
// @ts-ignore
searchInstalledPackage.mockImplementation(() => {
return {
installedNodes: [],
};
});
const updatedNpmModuleMock = jest.fn(() => ({
installedNodes: [],
}));
// @ts-ignore
LoadNodesAndCredentials.mockImplementation(() => {
return {
updateNpmModule: updatedNpmModuleMock,
};
});
const response = await authOwnerAgent.patch('/nodes').send(requestBody);
expect(updatedNpmModuleMock).toHaveBeenCalledTimes(1);
});
async function saveMockPackage(payload: InstalledPackagePayload) {
return await testDb.saveInstalledPackage(payload);
}
async function saveMockNode(payload: InstalledNodePayload) {
return await testDb.saveInstalledNode(payload);
}
function getNpmOutdatedError(packageName: string) {
const errorOutput = new Error('Something went wrong');
// @ts-ignore
errorOutput.code = 1;
// @ts-ignore
errorOutput.stdout = '{' +
`"${packageName}": {` +
`"current": "${CURRENT_PACKAGE_VERSION}",` +
`"wanted": "${CURRENT_PACKAGE_VERSION}",` +
`"latest": "${UPDATED_PACKAGE_VERSION}",` +
`"location": "node_modules/${packageName}"` +
'}' +
'}';
return errorOutput;
}

View file

@ -71,6 +71,17 @@ export const BOOTSTRAP_POSTGRES_CONNECTION_NAME: Readonly<string> = 'n8n_bs_post
*/
export const BOOTSTRAP_MYSQL_CONNECTION_NAME: Readonly<string> = 'n8n_bs_mysql';
/**
* Timeout (in milliseconds) to account for fake SMTP service being slow to respond.
*/
export const SMTP_TEST_TIMEOUT = 30_000;
/**
* Nodes
*/
export const CURRENT_PACKAGE_VERSION = '0.1.0';
export const UPDATED_PACKAGE_VERSION = '0.2.0';
/**
* Timeout (in milliseconds) to account for DB being slow to initialize.
*/

View file

@ -24,8 +24,10 @@ import { categorize, getPostgresSchemaSection } from './utils';
import { createCredentiasFromCredentialsEntity } from '../../../src/CredentialsHelper';
import type { Role } from '../../../src/databases/entities/Role';
import type { CollectionName, CredentialPayload, InstalledNodePayload, InstalledPackagePayload, MappingName } from './types';
import { InstalledPackages } from '../../../src/databases/entities/InstalledPackages';
import { InstalledNodes } from '../../../src/databases/entities/InstalledNodes';
import { User } from '../../../src/databases/entities/User';
import type { CollectionName, CredentialPayload, MappingName } from './types';
import { WorkflowEntity } from '../../../src/databases/entities/WorkflowEntity';
import { ExecutionEntity } from '../../../src/databases/entities/ExecutionEntity';
import { TagEntity } from '../../../src/databases/entities/TagEntity';
@ -258,6 +260,8 @@ function toTableName(sourceName: CollectionName | MappingName) {
SharedCredentials: 'shared_credentials',
SharedWorkflow: 'shared_workflow',
Settings: 'settings',
InstalledPackages: 'installed_packages',
InstalledNodes: 'installed_nodes',
}[sourceName];
}
@ -338,6 +342,29 @@ export function createUserShell(globalRole: Role): Promise<User> {
return Db.collections.User.save(shell);
}
// --------------------------------------
// Installed nodes and packages creation
// --------------------------------------
export async function saveInstalledPackage(installedPackagePayload: InstalledPackagePayload): Promise<InstalledPackages> {
const newInstalledPackage = new InstalledPackages();
Object.assign(newInstalledPackage, installedPackagePayload);
const savedInstalledPackage = await Db.collections.InstalledPackages.save(newInstalledPackage);
return savedInstalledPackage;
}
export async function saveInstalledNode(installedNodePayload: InstalledNodePayload): Promise<InstalledNodes> {
const newInstalledNode = new InstalledNodes();
Object.assign(newInstalledNode, installedNodePayload);
const savedInstalledNode = await Db.collections.InstalledNodes.save(newInstalledNode);
return savedInstalledNode;
}
export function addApiKey(user: User): Promise<User> {
user.apiKey = randomApiKey();
return Db.collections.User.save(user);

View file

@ -17,7 +17,8 @@ type EndpointGroup =
| 'owner'
| 'passwordReset'
| 'credentials'
| 'publicApi';
| 'publicApi'
| 'nodes';
export type CredentialPayload = {
name: string;
@ -43,3 +44,16 @@ export interface TriggerTime {
weekeday: number;
[key: string]: string | number;
}
export type InstalledPackagePayload = {
packageName: string;
installedVersion: string;
}
export type InstalledNodePayload = {
name: string;
type: string;
latestVersion: string;
package: string;
}

View file

@ -23,8 +23,8 @@ import {
} from 'n8n-workflow';
import config from '../../../config';
import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from './constants';
import { AUTH_COOKIE_NAME } from '../../../src/constants';
import { AUTHLESS_ENDPOINTS, CURRENT_PACKAGE_VERSION, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from './constants';
import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '../../../src/constants';
import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes';
import {
ActiveWorkflowRunner,
@ -44,8 +44,17 @@ import { getLogger } from '../../../src/Logger';
import { credentialsController } from '../../../src/api/credentials.api';
import { loadPublicApiVersions } from '../../../src/PublicApi/';
import type { User } from '../../../src/databases/entities/User';
import type { ApiPath, EndpointGroup, PostgresSchemaSection, TriggerTime } from './types';
import type {
ApiPath,
EndpointGroup,
InstalledNodePayload,
InstalledPackagePayload,
PostgresSchemaSection,
TriggerTime,
} from './types';
import type { N8nApp } from '../../../src/UserManagement/Interfaces';
import { nodesController } from '../../../src/api/nodes.api';
import { randomName } from './random';
/**
* Initialize a test server.
@ -89,6 +98,7 @@ export async function initTestServer({
const { apiRouters } = await loadPublicApiVersions(testServer.publicApiEndpoint);
const map: Record<string, express.Router | express.Router[]> = {
credentials: credentialsController,
nodes: nodesController,
publicApi: apiRouters,
};
@ -136,9 +146,7 @@ const classifyEndpointGroups = (endpointGroups: string[]) => {
const functionEndpoints: string[] = [];
endpointGroups.forEach((group) =>
(group === 'credentials' || group === 'publicApi' ? routerEndpoints : functionEndpoints).push(
group,
),
(['credentials', 'nodes', 'publicApi'].includes(group) ? routerEndpoints : functionEndpoints).push(group),
);
return [routerEndpoints, functionEndpoints];
@ -879,3 +887,24 @@ export function getPostgresSchemaSection(
return null;
}
// ----------------------------------
// nodes
// ----------------------------------
export function installedPackagePayload(): InstalledPackagePayload {
return {
packageName: NODE_PACKAGE_PREFIX + randomName(),
installedVersion: CURRENT_PACKAGE_VERSION,
};
}
export function installedNodePayload(packageName: string): InstalledNodePayload {
const nodeName = randomName();
return {
name: nodeName,
type: nodeName,
latestVersion: CURRENT_PACKAGE_VERSION,
package: packageName,
};
}

View file

@ -0,0 +1,331 @@
import { checkPackageStatus, matchPackagesWithUpdates, executeCommand, parsePackageName, matchMissingPackages, hasPackageLoadedSuccessfully, removePackageFromMissingList } from '../../src/CommunityNodes/helpers';
import { NODE_PACKAGE_PREFIX, NPM_COMMAND_TOKENS, NPM_PACKAGE_STATUS_GOOD, RESPONSE_ERROR_MESSAGES } from '../../src/constants';
jest.mock('fs/promises');
import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises';
jest.mock('child_process');
import { exec } from 'child_process';
import { InstalledPackages } from '../../src/databases/entities/InstalledPackages';
import { installedNodePayload, installedPackagePayload } from '../integration/shared/utils';
import { InstalledNodes } from '../../src/databases/entities/InstalledNodes';
import { NpmUpdatesAvailable } from '../../src/Interfaces';
import { randomName } from '../integration/shared/random';
import config from '../../config';
jest.mock('axios');
import axios from 'axios';
describe('CommunityNodesHelper', () => {
describe('parsePackageName', () => {
it('Should fail with empty package name', () => {
expect(() => parsePackageName('')).toThrowError()
});
it('Should fail with invalid package prefix name', () => {
expect(() => parsePackageName('INVALID_PREFIX@123')).toThrowError()
});
it('Should parse valid package name', () => {
const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name';
const parsedPackageName = parsePackageName(validPackageName);
expect(parsedPackageName.originalString).toBe(validPackageName);
expect(parsedPackageName.packageName).toBe(validPackageName);
expect(parsedPackageName.scope).toBeUndefined();
expect(parsedPackageName.version).toBeUndefined();
});
it('Should parse valid package name and version', () => {
const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name';
const validPackageVersion = '0.1.1';
const fullPackageName = `${validPackageName}@${validPackageVersion}`;
const parsedPackageName = parsePackageName(fullPackageName);
expect(parsedPackageName.originalString).toBe(fullPackageName);
expect(parsedPackageName.packageName).toBe(validPackageName);
expect(parsedPackageName.scope).toBeUndefined();
expect(parsedPackageName.version).toBe(validPackageVersion);
});
it('Should parse valid package name, scope and version', () => {
const validPackageScope = '@n8n';
const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name';
const validPackageVersion = '0.1.1';
const fullPackageName = `${validPackageScope}/${validPackageName}@${validPackageVersion}`;
const parsedPackageName = parsePackageName(fullPackageName);
expect(parsedPackageName.originalString).toBe(fullPackageName);
expect(parsedPackageName.packageName).toBe(`${validPackageScope}/${validPackageName}`);
expect(parsedPackageName.scope).toBe(validPackageScope);
expect(parsedPackageName.version).toBe(validPackageVersion);
});
});
describe('executeCommand', () => {
beforeEach(() => {
// @ts-ignore
fsAccess.mockReset();
// @ts-ignore
fsMkdir.mockReset();
// @ts-ignore
exec.mockReset();
});
it('Should call command with valid options', async () => {
// @ts-ignore
exec.mockImplementation((...args) => {
expect(args[1].cwd).toBeDefined();
expect(args[1].env).toBeDefined();
// PATH or NODE_PATH may be undefined depending on environment so we don't check for these keys.
const callbackFunction = args[args.length - 1];
callbackFunction(null, { stdout: 'Done' });
});
await executeCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toBeCalledTimes(0);
});
it ('Should make sure folder exists', async () => {
// @ts-ignore
exec.mockImplementation((...args) => {
const callbackFunction = args[args.length - 1];
callbackFunction(null, { stdout: 'Done' });
});
await executeCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toBeCalledTimes(0);
});
it ('Should try to create folder if it does not exist', async () => {
// @ts-ignore
exec.mockImplementation((...args) => {
const callbackFunction = args[args.length - 1];
callbackFunction(null, { stdout: 'Done' });
});
// @ts-ignore
fsAccess.mockImplementation(() => {
throw new Error('Folder does not exist.');
});
await executeCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toHaveBeenCalled();
});
it('Should throw especial error when package is not found', async() => {
// @ts-ignore
exec.mockImplementation((...args) => {
const callbackFunction = args[args.length - 1];
callbackFunction(new Error('Something went wrong - ' + NPM_COMMAND_TOKENS.NPM_PACKAGE_NOT_FOUND_ERROR + '. Aborting.'));
});
await expect(async () => await executeCommand('ls')).rejects.toThrow(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND);
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toHaveBeenCalledTimes(0);
});
});
describe('crossInformationPackage', () => {
it('Should return same list if availableUpdates is undefined', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const crossedData = matchPackagesWithUpdates(fakePackages);
expect(crossedData).toEqual(fakePackages);
});
it ('Should correctly match update versions for packages', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const updates: NpmUpdatesAvailable = {
[fakePackages[0].packageName]: {
current: fakePackages[0].installedVersion,
wanted: fakePackages[0].installedVersion,
latest: '0.2.0',
location: fakePackages[0].packageName,
},
[fakePackages[1].packageName]: {
current: fakePackages[0].installedVersion,
wanted: fakePackages[0].installedVersion,
latest: '0.3.0',
location: fakePackages[0].packageName,
}
};
const crossedData = matchPackagesWithUpdates(fakePackages, updates);
// @ts-ignore
expect(crossedData[0].updateAvailable).toBe('0.2.0');
// @ts-ignore
expect(crossedData[1].updateAvailable).toBe('0.3.0');
});
it ('Should correctly match update versions for single package', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const updates: NpmUpdatesAvailable = {
[fakePackages[1].packageName]: {
current: fakePackages[0].installedVersion,
wanted: fakePackages[0].installedVersion,
latest: '0.3.0',
location: fakePackages[0].packageName,
}
};
const crossedData = matchPackagesWithUpdates(fakePackages, updates);
// @ts-ignore
expect(crossedData[0].updateAvailable).toBeUndefined();
// @ts-ignore
expect(crossedData[1].updateAvailable).toBe('0.3.0');
});
});
describe('matchMissingPackages', () => {
it('Should not match failed packages that do not exist', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${NODE_PACKAGE_PREFIX}another-very-long-name-that-never-is-seen`;
const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList);
expect(matchedPackages).toEqual(fakePackages);
expect(matchedPackages[0].failedLoading).toBeUndefined();
expect(matchedPackages[1].failedLoading).toBeUndefined();
});
it('Should match failed packages that should be present', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${fakePackages[0].packageName}@${fakePackages[0].installedVersion}`;
const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList);
expect(matchedPackages[0].failedLoading).toBe(true);
expect(matchedPackages[1].failedLoading).toBeUndefined();
});
it('Should match failed packages even if version is wrong', () => {
const fakePackages = generateListOfFakeInstalledPackages();
const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${fakePackages[0].packageName}@123.456.789`;
const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList);
expect(matchedPackages[0].failedLoading).toBe(true);
expect(matchedPackages[1].failedLoading).toBeUndefined();
});
});
describe('checkPackageStatus', () => {
it('Should call axios.post', async () => {
const packageName = NODE_PACKAGE_PREFIX + randomName();
await checkPackageStatus(packageName);
expect(axios.post).toHaveBeenCalled();
});
it('Should not fail if request fails', async () => {
const packageName = NODE_PACKAGE_PREFIX + randomName();
axios.post = jest.fn(() => {
throw new Error('Something went wrong');
});
const result = await checkPackageStatus(packageName);
expect(result.status).toBe(NPM_PACKAGE_STATUS_GOOD);
});
it('Should warn if package is banned', async () => {
const packageName = NODE_PACKAGE_PREFIX + randomName();
// @ts-ignore
axios.post = jest.fn(() => {
return { data: { status: 'Banned', reason: 'Not good' } };
});
const result = await checkPackageStatus(packageName);
expect(result.status).toBe('Banned');
expect(result.reason).toBe('Not good');
});
});
describe('hasPackageLoadedSuccessfully', () => {
it('Should return true when failed package list does not exist', () => {
config.set('nodes.packagesMissing', undefined);
const result = hasPackageLoadedSuccessfully('package');
expect(result).toBe(true);
});
it('Should return true when package is not in the list of missing packages', () => {
config.set('nodes.packagesMissing', 'packageA@0.1.0 packgeB@0.1.0');
const result = hasPackageLoadedSuccessfully('packageC');
expect(result).toBe(true);
});
it('Should return false when package is in the list of missing packages', () => {
config.set('nodes.packagesMissing', 'packageA@0.1.0 packgeB@0.1.0');
const result = hasPackageLoadedSuccessfully('packageA');
expect(result).toBe(false);
});
});
describe('removePackageFromMissingList', () => {
it('Should do nothing if key does not exist', () => {
config.set('nodes.packagesMissing', undefined);
removePackageFromMissingList('packageA');
const packageList = config.get('nodes.packagesMissing');
expect(packageList).toBeUndefined();
});
it('Should remove only correct package from list', () => {
config.set('nodes.packagesMissing', 'packageA@0.1.0 packageB@0.2.0 packageBB@0.2.0');
removePackageFromMissingList('packageB');
const packageList = config.get('nodes.packagesMissing');
expect(packageList).toBe('packageA@0.1.0 packageBB@0.2.0');
});
it('Should not remove if package is not in the list', () => {
const failedToLoadList = 'packageA@0.1.0 packageB@0.2.0 packageBB@0.2.0';
config.set('nodes.packagesMissing', failedToLoadList);
removePackageFromMissingList('packageC');
const packageList = config.get('nodes.packagesMissing');
expect(packageList).toBe(failedToLoadList);
});
});
});
/**
* Generates a list with 2 packages, one with a single node and
* another with 2 nodes
* @returns
*/
function generateListOfFakeInstalledPackages(): InstalledPackages[] {
const fakeInstalledPackage1 = new InstalledPackages();
Object.assign(fakeInstalledPackage1, installedPackagePayload());
const fakeInstalledNode1 = new InstalledNodes();
Object.assign(fakeInstalledNode1, installedNodePayload(fakeInstalledPackage1.packageName));
fakeInstalledPackage1.installedNodes = [fakeInstalledNode1];
const fakeInstalledPackage2 = new InstalledPackages();
Object.assign(fakeInstalledPackage2, installedPackagePayload());
const fakeInstalledNode2 = new InstalledNodes();
Object.assign(fakeInstalledNode2, installedNodePayload(fakeInstalledPackage2.packageName));
const fakeInstalledNode3 = new InstalledNodes();
Object.assign(fakeInstalledNode3, installedNodePayload(fakeInstalledPackage2.packageName));
fakeInstalledPackage2.installedNodes = [fakeInstalledNode2, fakeInstalledNode3];
return [fakeInstalledPackage1, fakeInstalledPackage2];
}

View file

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
export const BINARY_ENCODING = 'base64';
export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS';
export const DOWNLOADED_NODES_SUBDIRECTORY = 'nodes';
export const ENCRYPTION_KEY_ENV_OVERWRITE = 'N8N_ENCRYPTION_KEY';
export const EXTENSIONS_SUBDIRECTORY = 'custom';
export const USER_FOLDER_ENV_OVERWRITE = 'N8N_USER_FOLDER';

View file

@ -9,6 +9,7 @@ import { createHash, randomBytes } from 'crypto';
import {
ENCRYPTION_KEY_ENV_OVERWRITE,
EXTENSIONS_SUBDIRECTORY,
DOWNLOADED_NODES_SUBDIRECTORY,
IUserSettings,
RESPONSE_ERROR_MESSAGES,
USER_FOLDER_ENV_OVERWRITE,
@ -265,6 +266,17 @@ export function getUserN8nFolderCustomExtensionPath(): string {
return path.join(getUserN8nFolderPath(), EXTENSIONS_SUBDIRECTORY);
}
/**
* Returns the path to the n8n user folder with the nodes that
* have been downloaded
*
* @export
* @returns {string}
*/
export function getUserN8nFolderDowloadedNodesPath(): string {
return path.join(getUserN8nFolderPath(), DOWNLOADED_NODES_SUBDIRECTORY);
}
/**
* Returns the home folder path of the user if
* none can be found it falls back to the current

View file

@ -5,6 +5,12 @@ export default {
title: 'Atoms/ActionBox',
component: N8nActionBox,
argTypes: {
calloutTheme: {
control: {
type: 'select',
options: ['info', 'success', 'warning', 'danger', 'custom'],
},
},
},
parameters: {
backgrounds: { default: '--color-background-light' },

View file

@ -3,12 +3,15 @@
<div :class="$style.heading" v-if="props.heading">
<component :is="$options.components.N8nHeading" size="xlarge" align="center">{{ props.heading }}</component>
</div>
<div :class="$style.description">
<div :class="$style.description" @click="(e) => listeners.descriptionClick && listeners.descriptionClick(e)">
<n8n-text color="text-base"><span v-html="props.description"></span></n8n-text>
</div>
<component :is="$options.components.N8nButton" :label="props.buttonText" size="large"
<component v-if="props.buttonText" :is="$options.components.N8nButton" :label="props.buttonText" size="large"
@click="(e) => listeners.click && listeners.click(e)"
/>
<component v-if="props.calloutText" :is="$options.components.N8nCallout"
:theme="props.calloutTheme" :message="props.calloutText" :icon="props.calloutIcon"
/>
</div>
</template>
@ -16,6 +19,7 @@
import N8nButton from '../N8nButton';
import N8nHeading from '../N8nHeading';
import N8nText from '../N8nText';
import N8nCallout from '../N8nCallout';
export default {
name: 'n8n-action-box',
@ -29,11 +33,22 @@ export default {
description: {
type: String,
},
calloutText: {
type: String,
},
calloutTheme: {
type: String,
default: 'info',
},
calloutIcon: {
type: String,
},
},
components: {
N8nButton,
N8nHeading,
N8nText,
N8nCallout,
},
};
</script>
@ -61,6 +76,7 @@ export default {
}
.description {
color: var(--color-text-base);
margin-bottom: var(--spacing-xl);
}
</style>

View file

@ -15,6 +15,15 @@
:disabled="action.disabled"
>
{{action.label}}
<div :class="$style.iconContainer">
<component
v-if="action.type === 'external-link'"
:is="$options.components.N8nIcon"
icon="external-link-alt"
size="xsmall"
color="text-base"
/>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
@ -100,4 +109,12 @@ export default {
background-color: var(--color-background-xlight);
}
}
.iconContainer {
display: inline;
}
li:hover .iconContainer svg {
color: var(--color-primary-tint-1);
}
</style>

View file

@ -0,0 +1,40 @@
import N8nCallout from './Callout.vue';
import { StoryFn } from '@storybook/vue';
export default {
title: 'Atoms/Callout',
component: N8nCallout,
argTypes: {
theme: {
control: {
type: 'select',
options: ['info', 'success', 'warning', 'danger', 'custom'],
},
},
message: {
control: {
type: 'text',
},
},
icon: {
control: {
type: 'text',
},
},
},
};
const template : StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nCallout,
},
template: `<n8n-callout v-bind="$props"></n8n-callout>`,
});
export const callout = template.bind({});
callout.args = {
theme: 'custom',
icon: 'code-branch',
message: 'This is a callout. <a href="https://n8n.io" target="_blank">Read more.</a>',
};

View file

@ -0,0 +1,103 @@
<template>
<div :class="classes" role="alert">
<div :class="$style.icon">
<n8n-icon v-bind="$attrs" :icon="getIcon" size="large"/>
</div>
<div :class="$style.message" >
<n8n-text size="small" v-bind="$attrs"><span v-html="message"></span></n8n-text>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import N8nIcon from '../N8nIcon';
import N8nText from '../N8nText';
export default Vue.extend({
name: 'n8n-callout',
components: {
N8nIcon,
N8nText
},
props: {
theme: {
type: String,
required: true,
validator: (value: string): boolean =>
['info', 'success', 'warning', 'danger', 'custom'].includes(value),
},
message: {
type: String,
required: true
},
icon: {
type: String,
default: 'info-circle'
}
},
data() {
return {
defaultIcons: {
'info': 'info-circle',
'success': 'check-circle',
'warning': 'exclamation-triangle',
'danger': 'times-circle',
}
}
},
computed: {
classes(): string[] {
return [
this.$style['callout'],
this.$style[this.theme],
];
},
getIcon(): string {
if(this.theme === 'custom') {
return this.icon;
}
return this.defaultIcons[this.theme];
},
}
});
</script>
<style lang="scss" module>
.callout {
display: flex;
font-size: var(--font-size-2xs);
padding: var(--spacing-xs);
border: var(--border-width-base) var(--border-style-base);
border-radius: var(--border-radius-base);
align-items: center;
}
.info, .custom {
border-color: var(--color-foreground-base);
background-color: var(--color-background-light);
color: var(--color-info);
}
.warning {
border-color: var(--color-warning-tint-1);
background-color: var(--color-warning-tint-2);
color: var(--color-warning);
}
.success {
border-color: var(--color-success-tint-1);
background-color: var(--color-success-tint-2);
color: var(--color-success);
}
.danger {
border-color: var(--color-danger-tint-1);
background-color: var(--color-danger-tint-2);
color: var(--color-danger);
}
.icon {
margin-right: var(--spacing-xs);
}
</style>

View file

@ -0,0 +1,107 @@
import { render } from '@testing-library/vue';
import N8nCallout from '../Callout.vue';
describe('components', () => {
describe('N8NCallout', () => {
describe('props', () => {
it('should render info theme correctly', () => {
const wrapper = render(N8nCallout, {
props: {
theme: 'info',
message: 'This is an info callout.',
},
stubs: [
'n8n-icon',
'n8n-text',
],
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render success theme correctly', () => {
const wrapper = render(N8nCallout, {
props: {
theme: 'success',
message: 'This is an success callout.',
},
stubs: [
'n8n-icon',
'n8n-text',
],
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render warning theme correctly', () => {
const wrapper = render(N8nCallout, {
props: {
theme: 'warning',
message: 'This is an warning callout.',
},
stubs: [
'n8n-icon',
'n8n-text',
],
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render danger theme correctly', () => {
const wrapper = render(N8nCallout, {
props: {
theme: 'danger',
message: 'This is an danger callout.',
},
stubs: [
'n8n-icon',
'n8n-text',
],
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render custom theme correctly', () => {
const wrapper = render(N8nCallout, {
props: {
theme: 'custom',
message: 'This is an custom callout.',
icon: 'code',
},
stubs: [
'n8n-icon',
'n8n-text',
],
});
expect(wrapper.html()).toMatchSnapshot();
});
});
describe('content', () => {
it('should render custom HTML content correctly', () => {
const wrapper = render(N8nCallout, {
props: {
theme: 'custom',
message: 'This is an HTML callout. <a href="#" target="_blank"><b>Read more</b></a>',
icon: 'code',
},
stubs: [
'n8n-icon',
'n8n-text',
],
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should pass props to text component correctly', () => {
const wrapper = render(N8nCallout, {
props: {
theme: 'warning',
message: 'This is a callout.',
bold: true,
align: 'center',
tag: 'p',
},
stubs: [
'n8n-icon',
'n8n-text',
],
});
expect(wrapper.html()).toMatchSnapshot();
});
});
});
});

View file

@ -0,0 +1,78 @@
// Vitest Snapshot v1
exports[`components > N8NCallout > content > should pass props to text component correctly 1`] = `
"<div role=\\"alert\\" class=\\"_callout_a6gr6_1 _warning_a6gr6_16\\" bold=\\"true\\" align=\\"center\\" tag=\\"p\\">
<div class=\\"_icon_a6gr6_34\\">
<n8n-icon-stub icon=\\"exclamation-triangle\\" size=\\"large\\" bold=\\"true\\" align=\\"center\\" tag=\\"p\\"></n8n-icon-stub>
</div>
<div>
<n8n-text-stub bold=\\"true\\" size=\\"small\\" align=\\"center\\" tag=\\"p\\"><span>This is a callout.</span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8NCallout > content > should render custom HTML content correctly 1`] = `
"<div role=\\"alert\\" class=\\"_callout_a6gr6_1 _custom_a6gr6_10\\">
<div class=\\"_icon_a6gr6_34\\">
<n8n-icon-stub icon=\\"code\\" size=\\"large\\"></n8n-icon-stub>
</div>
<div>
<n8n-text-stub size=\\"small\\" tag=\\"span\\"><span>This is an HTML callout. <a href=\\"#\\" target=\\"_blank\\"><b>Read more</b></a></span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8NCallout > props > should render custom theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"_callout_a6gr6_1 _custom_a6gr6_10\\">
<div class=\\"_icon_a6gr6_34\\">
<n8n-icon-stub icon=\\"code\\" size=\\"large\\"></n8n-icon-stub>
</div>
<div>
<n8n-text-stub size=\\"small\\" tag=\\"span\\"><span>This is an custom callout.</span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8NCallout > props > should render danger theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"_callout_a6gr6_1 _danger_a6gr6_28\\">
<div class=\\"_icon_a6gr6_34\\">
<n8n-icon-stub icon=\\"times-circle\\" size=\\"large\\"></n8n-icon-stub>
</div>
<div>
<n8n-text-stub size=\\"small\\" tag=\\"span\\"><span>This is an danger callout.</span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8NCallout > props > should render info theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"_callout_a6gr6_1 _info_a6gr6_10\\">
<div class=\\"_icon_a6gr6_34\\">
<n8n-icon-stub icon=\\"info-circle\\" size=\\"large\\"></n8n-icon-stub>
</div>
<div>
<n8n-text-stub size=\\"small\\" tag=\\"span\\"><span>This is an info callout.</span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8NCallout > props > should render success theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"_callout_a6gr6_1 _success_a6gr6_22\\">
<div class=\\"_icon_a6gr6_34\\">
<n8n-icon-stub icon=\\"check-circle\\" size=\\"large\\"></n8n-icon-stub>
</div>
<div>
<n8n-text-stub size=\\"small\\" tag=\\"span\\"><span>This is an success callout.</span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8NCallout > props > should render warning theme correctly 1`] = `
"<div role=\\"alert\\" class=\\"_callout_a6gr6_1 _warning_a6gr6_16\\">
<div class=\\"_icon_a6gr6_34\\">
<n8n-icon-stub icon=\\"exclamation-triangle\\" size=\\"large\\"></n8n-icon-stub>
</div>
<div>
<n8n-text-stub size=\\"small\\" tag=\\"span\\"><span>This is an warning callout.</span></n8n-text-stub>
</div>
</div>"
`;

View file

@ -0,0 +1,3 @@
import N8nCallout from './Callout.vue';
export default N8nCallout;

View file

@ -10,7 +10,7 @@ export default {
size: {
control: {
type: 'select',
options: ['small', 'medium', 'large'],
options: ['xsmall', 'small', 'medium', 'large'],
},
},
spin: {

View file

@ -37,6 +37,7 @@ export default {
default: false,
},
color: {
type: String,
},
},
};
@ -59,4 +60,8 @@ export default {
.small {
width: var(--font-size-2xs) !important;
}
.xsmall {
width: var(--font-size-3xs) !important;
}
</style>

View file

@ -7,28 +7,35 @@
<n8n-icon icon="chevron-right" size="small" />
</div>
<div ref="tabs" :class="$style.tabs">
<div v-for="option in options" :key="option.value" :class="{ [$style.alignRight]: option.align === 'right' }">
<a
v-if="option.href"
target="_blank"
:href="option.href"
:class="[$style.link, $style.tab]"
@click="() => handleTabClick(option.value)"
>
<div>
{{ option.label }}
<span :class="$style.external"><n8n-icon icon="external-link-alt" size="small" /></span>
</div>
</a>
<div v-for="option in options"
:key="option.value"
:id="option.value"
:class="{ [$style.alignRight]: option.align === 'right' }"
>
<n8n-tooltip :disabled="!option.tooltip" placement="bottom">
<div slot="content" v-html="option.tooltip" @click="handleTooltipClick(option.value, $event)"></div>
<a
v-if="option.href"
target="_blank"
:href="option.href"
:class="[$style.link, $style.tab]"
@click="() => handleTabClick(option.value)"
>
<div>
{{ option.label }}
<span :class="$style.external"><n8n-icon icon="external-link-alt" size="small" /></span>
</div>
</a>
<div
v-else
:class="{ [$style.tab]: true, [$style.activeTab]: value === option.value }"
@click="() => handleTabClick(option.value)"
>
<n8n-icon v-if="option.icon" :icon="option.icon" size="medium" />
<span v-if="option.label">{{ option.label }}</span>
</div>
<div
v-else
:class="{ [$style.tab]: true, [$style.activeTab]: value === option.value }"
@click="() => handleTabClick(option.value)"
>
<n8n-icon v-if="option.icon" :icon="option.icon" size="medium" />
<span v-if="option.label">{{ option.label }}</span>
</div>
</n8n-tooltip>
</div>
</div>
</div>
@ -82,6 +89,9 @@ export default Vue.extend({
},
},
methods: {
handleTooltipClick(tab: string, event: MouseEvent) {
this.$emit('tooltipClick', tab, event);
},
handleTabClick(tab: string) {
this.$emit('input', tab);
},

View file

@ -39,6 +39,7 @@ import N8nActionToggle from './N8nActionToggle';
import N8nAvatar from './N8nAvatar';
import N8nBadge from './N8nBadge';
import N8nButton from './N8nButton';
import N8nCallout from './N8nCallout';
import N8nCard from './N8nCard';
import N8nFormBox from './N8nFormBox';
import N8nFormInput from './N8nFormInput';
@ -80,6 +81,7 @@ export {
N8nAvatar,
N8nBadge,
N8nButton,
N8nCallout,
N8nCard,
N8nHeading,
N8nFormBox,

View file

@ -129,7 +129,7 @@
);
--color-warning-tint-1-h: 35;
--color-warning-tint-1-s: 78%;
--color-warning-tint-1-s: 77%;
--color-warning-tint-1-l: 84%;
--color-warning-tint-1: hsl(
var(--color-warning-h),
@ -146,9 +146,9 @@
var(--color-warning-tint-2-l)
);
--color-danger-h: 0;
--color-danger-s: 87.6%;
--color-danger-l: 65.3%;
--color-danger-h: 355;
--color-danger-s: 83%;
--color-danger-l: 52%;
--color-danger: hsl(
var(--color-danger-h),
var(--color-danger-s),

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -21,8 +21,14 @@ import {
ITelemetrySettings,
IWorkflowSettings as IWorkflowSettingsWorkflow,
WorkflowExecuteMode,
PublicInstalledPackage,
} from 'n8n-workflow';
import {
COMMUNITY_PACKAGE_MANAGE_ACTIONS,
} from './constants';
export * from 'n8n-design-system/src/types';
declare module 'jsplumb' {
@ -138,7 +144,6 @@ export interface INodeUpdatePropertiesInformation {
export type XYPosition = [number, number];
export type MessageType = 'success' | 'warning' | 'info' | 'error';
export interface INodeUi extends INode {
position: XYPosition;
color?: string;
@ -411,6 +416,8 @@ export type IPushData =
| PushDataExecuteAfter
| PushDataExecuteBefore
| PushDataConsoleMessage
| PushDataReloadNodeType
| PushDataRemoveNodeType
| PushDataTestWebhook;
type PushDataExecutionFinished = {
@ -438,6 +445,16 @@ type PushDataConsoleMessage = {
type: 'sendConsoleMessage';
};
type PushDataReloadNodeType = {
data: IPushDataReloadNodeType;
type: 'reloadNodeType';
};
type PushDataRemoveNodeType = {
data: IPushDataRemoveNodeType;
type: 'removeNodeType';
};
type PushDataTestWebhook = {
data: IPushDataTestWebhook;
type: 'testWebhookDeleted' | 'testWebhookReceived';
@ -473,6 +490,15 @@ export interface IPushDataNodeExecuteBefore {
nodeName: string;
}
export interface IPushDataReloadNodeType {
name: string;
version: number;
}
export interface IPushDataRemoveNodeType {
name: string;
version: number;
}
export interface IPushDataTestWebhook {
executionId: string;
workflowId: string;
@ -557,13 +583,19 @@ export interface IUserManagementConfig {
export interface IPermissionGroup {
loginStatus?: ILogInStatus[];
role?: IRole[];
um?: boolean;
api?: boolean;
}
export interface IPermissionAllowGroup extends IPermissionGroup {
shouldAllow?: () => boolean;
}
export interface IPermissionDenyGroup extends IPermissionGroup {
shouldDeny?: () => boolean;
}
export interface IPermissions {
allow?: IPermissionGroup;
deny?: IPermissionGroup;
allow?: IPermissionAllowGroup;
deny?: IPermissionDenyGroup;
}
export interface IUserPermissions {
@ -661,6 +693,8 @@ export interface IN8nUISettings {
enabled: boolean;
host: string;
};
executionMode: string;
communityNodesEnabled: boolean;
publicApi: {
enabled: boolean;
latestVersion: number;
@ -826,6 +860,10 @@ export interface IRootState {
nodeMetadata: {[nodeName: string]: INodeMetadata};
}
export interface ICommunityPackageMap {
[name: string]: PublicInstalledPackage;
}
export interface ICredentialTypeMap {
[name: string]: ICredentialType;
}
@ -931,6 +969,11 @@ export interface IUsersState {
export interface IWorkflowsState {
}
export interface ICommunityNodesState {
availablePackageCount: number;
installedPackages: ICommunityPackageMap;
}
export interface IRestApiContext {
baseUrl: string;
sessionId: string;
@ -964,4 +1007,5 @@ export interface ITab {
href?: string;
icon?: string;
align?: 'right';
tooltip?: string;
}

View file

@ -0,0 +1,20 @@
import { IRestApiContext } from '@/Interface';
import { PublicInstalledPackage } from 'n8n-workflow';
import { get, post, makeRestApiRequest } from './helpers';
export async function getInstalledCommunityNodes(context: IRestApiContext): Promise<PublicInstalledPackage[]> {
const response = await get(context.baseUrl, '/nodes');
return response.data || [];
}
export async function installNewPackage(context: IRestApiContext, name: string): Promise<PublicInstalledPackage> {
return await post(context.baseUrl, '/nodes', { name });
}
export async function uninstallPackage(context: IRestApiContext, name: string): Promise<void> {
return await makeRestApiRequest(context, 'DELETE', '/nodes', { name });
}
export async function updatePackage(context: IRestApiContext, name: string): Promise<void> {
return await makeRestApiRequest(context, 'PATCH', '/nodes', { name });
}

View file

@ -52,7 +52,7 @@ async function request(config: {method: Method, baseURL: string, endpoint: strin
if (process.env.NODE_ENV !== 'production' && !baseURL.includes('api.n8n.io') ) {
options.withCredentials = true;
}
if (['PATCH', 'POST', 'PUT'].includes(method)) {
if (['PATCH', 'POST', 'PUT', 'DELETE'].includes(method)) {
options.data = data;
} else {
options.params = data;

View file

@ -1,6 +1,6 @@
import { IRestApiContext, IN8nPrompts, IN8nValueSurveyData, IN8nUISettings } from '../Interface';
import { makeRestApiRequest, get, post } from './helpers';
import { N8N_IO_BASE_URL } from '@/constants';
import { N8N_IO_BASE_URL, NPM_COMMUNITY_NODE_SEARCH_API_URL } from '@/constants';
export function getSettings(context: IRestApiContext): Promise<IN8nUISettings> {
return makeRestApiRequest(context, 'GET', '/settings');
@ -17,3 +17,9 @@ export async function submitContactInfo(instanceId: string, userId: string, emai
export async function submitValueSurvey(instanceId: string, userId: string, params: IN8nValueSurveyData): Promise<IN8nPrompts> {
return await post(N8N_IO_BASE_URL, '/value-survey', params, {'n8n-instance-id': instanceId, 'n8n-user-id': userId});
}
export async function getAvailableCommunityPackageCount(): Promise<number> {
const response = await get(NPM_COMMUNITY_NODE_SEARCH_API_URL, 'search?q=keywords:n8n-community-node-package');
return response.total || 0;
}

View file

@ -0,0 +1,181 @@
<template>
<div :class="$style.cardContainer">
<div v-if="loading" :class="$style.cardSkeleton">
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
</div>
<div v-else :class="$style.packageCard">
<div :class="$style.cardInfoContainer">
<div :class="$style.cardTitle">
<n8n-text :bold="true" size="large">{{ communityPackage.packageName }}</n8n-text>
</div>
<div :class="$style.cardSubtitle">
<n8n-text :bold="true" size="small" color="text-light">
{{
$locale.baseText('settings.communityNodes.packageNodes.label', {
adjustToNumber: communityPackage.installedNodes.length,
})
}}:&nbsp;
</n8n-text>
<n8n-text size="small" color="text-light">
<span v-for="(node, index) in communityPackage.installedNodes" :key="node.name">
{{ node.name }}<span v-if="index != communityPackage.installedNodes.length - 1">,</span>
</span>
</n8n-text>
</div>
</div>
<div :class="$style.cardControlsContainer">
<n8n-text :bold="true" size="large" color="text-light">
v{{ communityPackage.installedVersion }}
</n8n-text>
<n8n-tooltip v-if="communityPackage.failedLoading === true" placement="top">
<div slot="content">
{{ $locale.baseText('settings.communityNodes.failedToLoad.tooltip') }}
</div>
<n8n-icon icon="exclamation-triangle" color="danger" size="large" />
</n8n-tooltip>
<n8n-tooltip v-else-if="communityPackage.updateAvailable" placement="top">
<div slot="content">
{{ $locale.baseText('settings.communityNodes.updateAvailable.tooltip') }}
</div>
<n8n-button type="outline" label="Update" @click="onUpdateClick"/>
</n8n-tooltip>
<n8n-tooltip v-else placement="top">
<div slot="content">
{{ $locale.baseText('settings.communityNodes.upToDate.tooltip') }}
</div>
<n8n-icon icon="check-circle" color="text-light" size="large" />
</n8n-tooltip>
<div :class="$style.cardActions">
<n8n-action-toggle :actions="packageActions" @action="onAction"></n8n-action-toggle>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { PublicInstalledPackage } from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
import {
NPM_PACKAGE_DOCS_BASE_URL,
COMMUNITY_PACKAGE_MANAGE_ACTIONS,
} from '../constants';
import { showMessage } from './mixins/showMessage';
export default mixins(
showMessage,
).extend({
name: 'CommunityPackageCard',
props: {
communityPackage: {
type: Object as () => PublicInstalledPackage,
},
loading: {
type: Boolean,
default: false,
},
},
data() {
return {
packageActions: [
{
label: this.$locale.baseText('settings.communityNodes.viewDocsAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS,
type: 'external-link',
},
{
label: this.$locale.baseText('settings.communityNodes.uninstallAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL,
},
],
};
},
methods: {
async onAction(value: string) {
switch (value) {
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS:
this.$telemetry.track('user clicked to browse the cnr package documentation', {
package_name: this.communityPackage.packageName,
package_version: this.communityPackage.installedVersion,
});
window.open(`${NPM_PACKAGE_DOCS_BASE_URL}${this.communityPackage.packageName}`, '_blank');
break;
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL:
this.$store.dispatch('ui/openCommunityPackageUninstallConfirmModal', this.communityPackage.packageName);
break;
default:
break;
}
},
onUpdateClick() {
this.$store.dispatch('ui/openCommunityPackageUpdateConfirmModal', this.communityPackage.packageName);
},
},
});
</script>
<style lang="scss" module>
.cardContainer {
display: flex;
padding: var(--spacing-s);
border: var(--border-width-base) var(--border-style-base) var(--color-info-tint-1);
border-radius: var(--border-radius-large);
background-color: var(--color-background-xlight);
}
.packageCard, .cardSkeleton {
display: flex;
flex-basis: 100%;
justify-content: space-between;
}
.packageCard {
align-items: center;
}
.cardSkeleton {
flex-direction: column;
}
.loader {
width: 50%;
transform: scaleY(-1);
&:last-child {
width: 70%;
div {
margin: 0;
}
}
}
.cardInfoContainer {
display: flex;
flex-wrap: wrap;
}
.cardTitle {
flex-basis: 100%;
span {
line-height: 1;
}
}
.cardSubtitle {
margin-top: 2px;
padding-right: var(--spacing-m);
}
.cardControlsContainer {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
}
.cardActions {
padding-left: var(--spacing-3xs);
}
</style>

View file

@ -0,0 +1,226 @@
<template>
<Modal
width="540px"
:name="COMMUNITY_PACKAGE_INSTALL_MODAL_KEY"
:title="$locale.baseText('settings.communityNodes.installModal.title')"
:eventBus="modalBus"
:center="true"
:beforeClose="onModalClose"
:showClose="!loading"
>
<template slot="content">
<div :class="[$style.descriptionContainer, 'p-s']">
<div>
<n8n-text>
{{ $locale.baseText('settings.communityNodes.installModal.description') }}
</n8n-text> <n8n-link
:to="COMMUNITY_NODES_INSTALLATION_DOCS_URL"
@click="onMoreInfoTopClick"
>
{{ $locale.baseText('_reusableDynamicText.moreInfo') }}
</n8n-link>
</div>
<n8n-button
:label="$locale.baseText('settings.communityNodes.browseButton.label')"
icon="external-link-alt"
:class="$style.browseButton"
@click="openNPMPage"
/>
</div>
<div :class="[$style.formContainer, 'mt-m']">
<n8n-input-label
:class="$style.labelTooltip"
:label="$locale.baseText('settings.communityNodes.installModal.packageName.label')"
:tooltipText="$locale.baseText('settings.communityNodes.installModal.packageName.tooltip',
{ interpolate: { npmURL: NPM_KEYWORD_SEARCH_URL } }
)"
>
<n8n-input
name="packageNameInput"
v-model="packageName"
type="text"
:maxlength="214"
:placeholder="$locale.baseText('settings.communityNodes.installModal.packageName.placeholder')"
:required="true"
:disabled="loading"
@blur="onInputBlur"
/>
</n8n-input-label>
<div :class="[$style.infoText, 'mt-4xs']">
<span
size="small"
:class="[$style.infoText, infoTextErrorMessage ? $style.error : '']"
v-html="infoTextErrorMessage"
></span>
</div>
<el-checkbox
v-model="userAgreed"
:class="[$style.checkbox, checkboxWarning ? $style.error : '', 'mt-l']"
:disabled="loading"
@change="onCheckboxChecked"
>
<n8n-text>
{{ $locale.baseText('settings.communityNodes.installModal.checkbox.label') }}
</n8n-text><br />
<n8n-link :to="COMMUNITY_NODES_RISKS_DOCS_URL" @click="onLearnMoreLinkClick">{{ $locale.baseText('_reusableDynamicText.moreInfo') }}</n8n-link>
</el-checkbox>
</div>
</template>
<template slot="footer">
<n8n-button
:loading="loading"
:disabled="packageName === '' || loading"
:label="loading ?
$locale.baseText('settings.communityNodes.installModal.installButton.label.loading') :
$locale.baseText('settings.communityNodes.installModal.installButton.label')"
size="large"
float="right"
@click="onInstallClick"
/>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import Modal from './Modal.vue';
import {
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
NPM_KEYWORD_SEARCH_URL,
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
COMMUNITY_NODES_RISKS_DOCS_URL,
} from '../constants';
import mixins from 'vue-typed-mixins';
import { showMessage } from './mixins/showMessage';
export default mixins(
showMessage,
).extend({
name: 'CommunityPackageInstallModal',
components: {
Modal,
},
data() {
return {
loading: false,
packageName: '',
userAgreed: false,
modalBus: new Vue(),
checkboxWarning: false,
infoTextErrorMessage: '',
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
NPM_KEYWORD_SEARCH_URL,
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
COMMUNITY_NODES_RISKS_DOCS_URL,
};
},
methods: {
openNPMPage() {
this.$telemetry.track('user clicked cnr browse button', { source: 'cnr install modal' });
window.open(NPM_KEYWORD_SEARCH_URL, '_blank');
},
async onInstallClick() {
if (!this.userAgreed) {
this.checkboxWarning = true;
} else {
try {
this.$telemetry.track('user started cnr package install', { input_string: this.packageName, source: 'cnr settings page' });
this.infoTextErrorMessage = '';
this.loading = true;
await this.$store.dispatch('communityNodes/installPackage', this.packageName);
// TODO: We need to fetch a fresh list of installed packages until proper response is implemented on the back-end
await this.$store.dispatch('communityNodes/fetchInstalledPackages');
this.loading = false;
this.modalBus.$emit('close');
this.$showMessage({
title: this.$locale.baseText('settings.communityNodes.messages.install.success'),
type: 'success',
});
} catch(error) {
if(error.httpStatusCode && error.httpStatusCode === 400) {
this.infoTextErrorMessage = error.message;
} else {
this.$showError(
error,
this.$locale.baseText('settings.communityNodes.messages.install.error'),
);
}
} finally {
this.loading = false;
}
}
},
onCheckboxChecked() {
this.checkboxWarning = false;
},
onModalClose() {
return !this.loading;
},
onInputBlur() {
this.packageName = this.packageName.replaceAll('npm i ', '').replaceAll('npm install ', '');
},
onMoreInfoTopClick() {
this.$telemetry.track('user clicked cnr docs link', { source: 'install package modal top' });
},
onLearnMoreLinkClick() {
this.$telemetry.track('user clicked cnr docs link', { source: 'install package modal bottom' });
},
},
});
</script>
<style module lang="scss">
.descriptionContainer {
display: flex;
justify-content: space-between;
align-items: center;
border: var(--border-width-base) var(--border-style-base) var(--color-info-tint-1);
border-radius: var(--border-radius-base);
background-color: var(--color-background-light);
button {
& > span {
flex-direction: row-reverse;
& > span {
margin-left: var(--spacing-3xs);
}
}
}
}
.formContainer {
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-regular);
color: var(--color-text-base);
}
.checkbox {
span:nth-child(2) {
vertical-align: text-top;
}
}
.error {
color: var(--color-danger);
span {
border-color: var(--color-danger);
}
}
</style>
<style lang="scss">
.el-tooltip__popper {
max-width: 240px;
img {
width: 100%;
}
p {
line-height: 1.2;
}
p + p {
margin-top: var(--spacing-2xs);
}
}
</style>

View file

@ -0,0 +1,183 @@
<template>
<Modal
width="540px"
:name="COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY"
:title="getModalContent.title"
:eventBus="modalBus"
:center="true"
:showClose="!loading"
:beforeClose="onModalClose"
>
<template slot="content">
<n8n-text>{{ getModalContent.message }}</n8n-text>
<div :class="$style.descriptionContainer" v-if="this.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UPDATE">
<n8n-info-tip theme="info" type="note" :bold="false">
<template>
<span v-html="getModalContent.description"></span>
</template>
</n8n-info-tip>
</div>
</template>
<template slot="footer">
<n8n-button
:loading="loading"
:disabled="loading"
:label="loading ? getModalContent.buttonLoadingLabel : getModalContent.buttonLabel"
size="large"
float="right"
@click="onConfirmButtonClick"
/>
</template>
</Modal>
</template>
<script>
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import Modal from './Modal.vue';
import { COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '../constants';
import { showMessage } from './mixins/showMessage';
export default mixins(showMessage).extend({
name: 'CommunityPackageManageConfirmModal',
components: {
Modal,
},
props: {
modalName: {
type: String,
required: true,
},
activePackageName: {
type: String,
required: true,
},
mode: {
type: String,
},
},
data() {
return {
loading: false,
modalBus: new Vue(),
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_MANAGE_ACTIONS,
};
},
computed: {
activePackage() {
return this.$store.getters['communityNodes/getInstalledPackageByName'](this.activePackageName);
},
getModalContent() {
if (this.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL) {
return {
title: this.$locale.baseText('settings.communityNodes.confirmModal.uninstall.title'),
message: this.$locale.baseText('settings.communityNodes.confirmModal.uninstall.message', {
interpolate: {
packageName: this.activePackageName,
},
}),
buttonLabel: this.$locale.baseText('settings.communityNodes.confirmModal.uninstall.buttonLabel'),
buttonLoadingLabel: this.$locale.baseText('settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel'),
};
}
return {
title: this.$locale.baseText('settings.communityNodes.confirmModal.update.title', {
interpolate: {
packageName: this.activePackageName,
},
}),
description: this.$locale.baseText('settings.communityNodes.confirmModal.update.description'),
message: this.$locale.baseText('settings.communityNodes.confirmModal.update.message', {
interpolate: {
packageName: this.activePackageName,
version: this.activePackage.updateAvailable,
},
}),
buttonLabel: this.$locale.baseText('settings.communityNodes.confirmModal.update.buttonLabel'),
buttonLoadingLabel: this.$locale.baseText('settings.communityNodes.confirmModal.update.buttonLoadingLabel'),
};
},
},
methods: {
onModalClose() {
return !this.loading;
},
async onConfirmButtonClick() {
if (this.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL) {
await this.onUninstall();
} else if (this.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UPDATE) {
await this.onUpdate();
}
},
async onUninstall() {
try {
this.$telemetry.track('user started cnr package deletion', {
package_name: this.activePackage.packageName,
package_node_names: this.activePackage.installedNodes.map(node => node.name),
package_version: this.activePackage.installedVersion,
package_author: this.activePackage.authorName,
package_author_email: this.activePackage.authorEmail,
});
this.loading = true;
await this.$store.dispatch('communityNodes/uninstallPackage', this.activePackageName);
this.$showMessage({
title: this.$locale.baseText('settings.communityNodes.messages.uninstall.success.title'),
type: 'success',
});
} catch (error) {
this.$showError(error, this.$locale.baseText('settings.communityNodes.messages.uninstall.error'));
} finally {
this.loading = false;
this.modalBus.$emit('close');
}
},
async onUpdate() {
try {
this.$telemetry.track('user started cnr package update', {
package_name: this.activePackage.packageName,
package_node_names: this.activePackage.installedNodes.map(node => node.name),
package_version_current: this.activePackage.installedVersion,
package_version_new: this.activePackage.updateAvailable,
package_author: this.activePackage.authorName,
package_author_email: this.activePackage.authorEmail,
});
this.loading = true;
const updatedVersion = this.activePackage.updateAvailable;
await this.$store.dispatch('communityNodes/updatePackage', this.activePackageName);
this.$showMessage({
title: this.$locale.baseText('settings.communityNodes.messages.update.success.title'),
message: this.$locale.baseText('settings.communityNodes.messages.update.success.message', {
interpolate: {
packageName: this.activePackageName,
version: updatedVersion,
},
}),
type: 'success',
});
} catch (error) {
this.$showError(error, this.$locale.baseText('settings.communityNodes.messages.update.error.title'));
} finally {
this.loading = false;
this.modalBus.$emit('close');
}
},
},
});
</script>
<style module lang="scss">
.descriptionContainer {
display: flex;
margin: var(--spacing-s) 0;
}
.descriptionIcon {
align-self: center;
color: var(--color-text-lighter);
}
.descriptionText {
padding: 0 var(--spacing-xs);
}
</style>

View file

@ -74,7 +74,7 @@
<script lang="ts">
import { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import { getAppNameFromCredType } from '../helpers';
import { getAppNameFromCredType, isCommunityPackageName } from '../helpers';
import Vue from 'vue';
import Banner from '../Banner.vue';
@ -84,6 +84,7 @@ import OauthButton from './OauthButton.vue';
import { restApi } from '@/components/mixins/restApi';
import { addCredentialTranslation } from '@/plugins/i18n';
import mixins from 'vue-typed-mixins';
import { NPM_PACKAGE_DOCS_BASE_URL } from '@/constants';
export default mixins(restApi).extend({
name: 'CredentialConfig',
@ -163,6 +164,8 @@ export default mixins(restApi).extend({
},
documentationUrl(): string {
const type = this.credentialType as ICredentialType;
const activeNode = this.$store.getters.activeNode;
const isCommunityNode = isCommunityPackageName(activeNode.type);
if (!type || !type.documentationUrl) {
return '';
@ -172,7 +175,9 @@ export default mixins(restApi).extend({
return type.documentationUrl;
}
return `https://docs.n8n.io/credentials/${type.documentationUrl}/?utm_source=n8n_app&utm_medium=left_nav_menu&utm_campaign=create_new_credentials_modal`;
return isCommunityNode ?
'' : // Don't show documentation link for community nodes if the URL is not an absolute path
`https://docs.n8n.io/credentials/${type.documentationUrl}/?utm_source=n8n_app&utm_medium=left_nav_menu&utm_campaign=create_new_credentials_modal`;
},
isGoogleOAuthType(): boolean {
return this.credentialTypeName === 'googleOAuth2Api' || this.parentTypes.includes('googleOAuth2Api');

View file

@ -259,11 +259,8 @@ export default mixins(
'isTemplatesEnabled',
]),
canUserAccessSettings(): boolean {
return [
VIEWS.PERSONAL_SETTINGS,
VIEWS.USERS_SETTINGS,
VIEWS.API_SETTINGS,
].some((route) => this.canUserAccessRouteByName(route));
const accessibleRoute = this.findFirstAccessibleSettingsRoute();
return accessibleRoute !== null;
},
helpMenuItems (): object[] {
return [
@ -616,19 +613,29 @@ export default mixins(
} else if (key === 'executions') {
this.$store.dispatch('ui/openModal', EXECUTIONS_MODAL_KEY);
} else if (key === 'settings') {
if (this.canUserAccessRouteByName(VIEWS.PERSONAL_SETTINGS) || this.canUserAccessRouteByName(VIEWS.USERS_SETTINGS)) {
if ((this.currentUser as IUser).isDefaultUser) {
this.$router.push('/settings/users');
}
else {
this.$router.push('/settings/personal');
}
}
else if (this.canUserAccessRouteByName(VIEWS.API_SETTINGS)) {
this.$router.push('/settings/api');
const defaultRoute = this.findFirstAccessibleSettingsRoute();
if (defaultRoute) {
const routeProps = this.$router.resolve({ name: defaultRoute });
this.$router.push(routeProps.route.path);
}
}
},
findFirstAccessibleSettingsRoute() {
// Get all settings rotes by filtering them by pageCategory property
const settingsRoutes = this.$router.getRoutes().filter(
category => category.meta.telemetry &&
category.meta.telemetry.pageCategory === 'settings',
).map(route => route.name || '');
let defaultSettingsRoute = null;
for (const route of settingsRoutes) {
if (this.canUserAccessRouteByName(route)) {
defaultSettingsRoute = route;
break;
}
}
return defaultSettingsRoute;
},
},
});
</script>

View file

@ -88,6 +88,20 @@
<ModalRoot :name="WORKFLOW_ACTIVE_MODAL_KEY">
<ActivationModal />
</ModalRoot>
<ModalRoot :name="COMMUNITY_PACKAGE_INSTALL_MODAL_KEY">
<CommunityPackageInstallModal />
</ModalRoot>
<ModalRoot :name="COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY">
<template v-slot="{ modalName, activeId, mode }">
<CommunityPackageManageConfirmModal
:modalName="modalName"
:activePackageName="activeId"
:mode="mode"
/>
</template>
</ModalRoot>
</div>
</template>
@ -96,6 +110,8 @@ import Vue from "vue";
import {
ABOUT_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_LIST_MODAL_KEY,
@ -114,6 +130,8 @@ import {
} from '@/constants';
import AboutModal from './AboutModal.vue';
import CommunityPackageManageConfirmModal from './CommunityPackageManageConfirmModal.vue';
import CommunityPackageInstallModal from './CommunityPackageInstallModal.vue';
import ChangePasswordModal from "./ChangePasswordModal.vue";
import ContactPromptModal from './ContactPromptModal.vue';
import CredentialEdit from "./CredentialEdit/CredentialEdit.vue";
@ -137,6 +155,8 @@ export default Vue.extend({
components: {
AboutModal,
ActivationModal,
CommunityPackageInstallModal,
CommunityPackageManageConfirmModal,
ContactPromptModal,
ChangePasswordModal,
CredentialEdit,
@ -155,6 +175,8 @@ export default Vue.extend({
WorkflowOpen,
},
data: () => ({
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_LIST_MODAL_KEY,

View file

@ -1,16 +1,16 @@
<template>
<div>
<div :class="$style.inputPanel" :style="inputPanelStyles">
<div :class="$style.inputPanel" v-if="!hideInputAndOutput" :style="inputPanelStyles">
<slot name="input"></slot>
</div>
<div :class="$style.outputPanel" :style="outputPanelStyles">
<div :class="$style.outputPanel" v-if="!hideInputAndOutput" :style="outputPanelStyles">
<slot name="output"></slot>
</div>
<div :class="$style.mainPanel" :style="mainPanelStyles">
<div :class="$style.dragButtonContainer" @click="close">
<PanelDragButton
:class="{ [$style.draggable]: true, [$style.visible]: isDragging }"
v-if="isDraggable"
v-if="!hideInputAndOutput && isDraggable"
:canMoveLeft="canMoveLeft"
:canMoveRight="canMoveRight"
@dragstart="onDragStart"
@ -42,6 +42,9 @@ export default Vue.extend({
isDraggable: {
type: Boolean,
},
hideInputAndOutput: {
type: Boolean,
},
position: {
type: Number,
},

View file

@ -15,9 +15,19 @@
})
}}
</span>
<span :class="$style['trigger-icon']">
<TriggerIcon v-if="isTrigger" />
<span v-if="isTrigger" :class="$style['trigger-icon']">
<TriggerIcon />
</span>
<n8n-tooltip v-if="isCommunityNode" placement="top">
<div
:class="$style['community-node-icon']"
slot="content"
v-html="$locale.baseText('generic.communityNode.tooltip', { interpolate: { packageName: nodeType.name.split('.')[0], docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL } })"
@click="onCommunityNodeTooltipClick"
>
</div>
<n8n-icon icon="cube" />
</n8n-tooltip>
</div>
<div :class="$style.description">
{{ $locale.headerText({
@ -50,6 +60,9 @@ import Vue from 'vue';
import NodeIcon from '../NodeIcon.vue';
import TriggerIcon from '../TriggerIcon.vue';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '../../constants';
import { isCommunityPackageName } from '../helpers';
Vue.component('NodeIcon', NodeIcon);
Vue.component('TriggerIcon', TriggerIcon);
@ -68,6 +81,7 @@ export default Vue.extend({
x: -100,
y: -100,
},
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
};
},
computed: {
@ -83,6 +97,9 @@ export default Vue.extend({
left: `${this.draggablePosition.x}px`,
};
},
isCommunityNode(): boolean {
return isCommunityPackageName(this.nodeType.name);
},
},
mounted() {
/**
@ -128,6 +145,11 @@ export default Vue.extend({
this.draggablePosition = { x: -100, y: -100 };
}, 300);
},
onCommunityNodeTooltipClick(event: MouseEvent) {
if ((event.target as Element).localName === 'a') {
this.$telemetry.track('user clicked cnr docs link', { source: 'nodes panel node' });
}
},
},
});
</script>
@ -145,7 +167,6 @@ export default Vue.extend({
}
.details {
display: flex;
align-items: center;
}
@ -162,6 +183,10 @@ export default Vue.extend({
margin-right: 5px;
}
.packageName {
margin-right: 5px;
}
.description {
margin-top: 2px;
font-size: 11px;
@ -173,7 +198,13 @@ export default Vue.extend({
.trigger-icon {
height: 16px;
width: 16px;
display: flex;
display: inline-block;
margin-right: var(--spacing-3xs);
vertical-align: middle;
}
.community-node-icon {
vertical-align: top;
}
.draggable {
@ -211,4 +242,8 @@ export default Vue.extend({
transform: scale(0);
}
}
.el-tooltip svg {
color: var(--color-foreground-xdark);
}
</style>

View file

@ -28,6 +28,8 @@
<div class="data-display" v-if="activeNode">
<div @click="close" :class="$style.modalBackground"></div>
<NDVDraggablePanels
:isTriggerNode="isTriggerNode"
:hideInputAndOutput="activeNodeType === null"
:position="isTriggerNode && !showTriggerPanel ? 0 : undefined"
:isDraggable="!isTriggerNode"
@close="close"

View file

@ -6,20 +6,43 @@
<div
v-if="!isReadOnly"
>
<NodeExecuteButton :nodeName="node.name" @execute="onNodeExecute" size="small" telemetrySource="parameters" />
<NodeExecuteButton v-if="node && nodeValid" :nodeName="node.name" @execute="onNodeExecute" size="small" telemetrySource="parameters"/>
</div>
</div>
<NodeSettingsTabs v-model="openPanel" :nodeType="nodeType" :sessionId="sessionId" />
<NodeSettingsTabs v-if="node && nodeValid" v-model="openPanel" :nodeType="nodeType" :sessionId="sessionId" />
</div>
<div class="node-is-not-valid" v-if="node && !nodeValid">
<n8n-text>
{{
$locale.baseText(
'nodeSettings.theNodeIsNotValidAsItsTypeIsUnknown',
{ interpolate: { nodeType: node.type } },
)
}}
</n8n-text>
<p :class="$style.warningIcon">
<font-awesome-icon icon="exclamation-triangle" />
</p>
<div class="missingNodeTitleContainer mt-s mb-xs">
<n8n-text size="large" color="text-dark" bold>
{{ $locale.baseText('nodeSettings.communityNodeUnknown.title') }}
</n8n-text>
</div>
<div v-if="isCommunityNode" :class="$style.descriptionContainer">
<div class="mb-l">
<span
v-html="$locale.baseText('nodeSettings.communityNodeUnknown.description', { interpolate: { packageName: node.type.split('.')[0] } })"
@click="onMissingNodeTextClick"
>
</span>
</div>
<n8n-link
:to="COMMUNITY_NODES_INSTALLATION_DOCS_URL"
@click="onMissingNodeLearnMoreLinkClick"
>
{{ $locale.baseText('nodeSettings.communityNodeUnknown.installLink.text') }}
</n8n-link>
</div>
<span v-else
v-html="
$locale.baseText('nodeSettings.nodeTypeUnknown.description',
{
interpolate: { docURL: CUSTOM_NODES_DOCS_URL }
})
">
</span>
</div>
<div class="node-parameters-wrapper" v-if="node && nodeValid">
<div v-show="openPanel === 'params'">
@ -77,6 +100,11 @@ import {
IUpdateInformation,
} from '@/Interface';
import {
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
CUSTOM_NODES_DOCS_URL,
} from '../constants';
import NodeTitle from '@/components/NodeTitle.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ParameterInputList from '@/components/ParameterInputList.vue';
@ -91,6 +119,7 @@ import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import mixins from 'vue-typed-mixins';
import NodeExecuteButton from './NodeExecuteButton.vue';
import { isCommunityPackageName } from './helpers';
export default mixins(
externalHooks,
@ -169,6 +198,9 @@ export default mixins(
return this.nodeType.properties;
},
isCommunityNode(): boolean {
return isCommunityPackageName(this.node.type);
},
},
props: {
eventBus: {
@ -289,7 +321,8 @@ export default mixins(
description: this.$locale.baseText('nodeSettings.notesInFlow.description'),
},
] as INodeProperties[],
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
CUSTOM_NODES_DOCS_URL,
};
},
watch: {
@ -552,6 +585,18 @@ export default mixins(
this.nodeValid = false;
}
},
onMissingNodeTextClick(event: MouseEvent) {
if ((event.target as Element).localName === 'a') {
this.$telemetry.track('user clicked cnr browse button', { source: 'cnr missing node modal' });
}
},
onMissingNodeLearnMoreLinkClick() {
this.$telemetry.track('user clicked cnr docs link', {
source: 'missing node modal source',
package_name: this.node.type.split('.')[0],
node_type: this.node.type,
});
},
},
mounted () {
this.setNodeValues();
@ -568,6 +613,16 @@ export default mixins(
.header {
background-color: var(--color-background-base);
}
.warningIcon {
color: var(--color-text-lighter);
font-size: var(--font-size-2xl);
}
.descriptionContainer {
display: flex;
flex-direction: column;
}
</style>
<style lang="scss">
@ -597,7 +652,14 @@ export default mixins(
}
.node-is-not-valid {
height: 75%;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
line-height: var(--font-line-height-regular);
}
.node-parameters-wrapper {
@ -659,5 +721,4 @@ export default mixins(
background-color: #793300;
}
}
</style>

View file

@ -3,15 +3,18 @@
:options="options"
:value="value"
@input="onTabSelect"
@tooltipClick="onTooltipClick"
/>
</template>
<script lang="ts">
import { externalHooks } from '@/components/mixins/externalHooks';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL, NPM_PACKAGE_DOCS_BASE_URL } from '@/constants';
import { INodeUi, ITab } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
import { isCommunityPackageName } from './helpers';
export default mixins(
externalHooks,
@ -33,6 +36,7 @@ export default mixins(
},
documentationUrl (): string {
const nodeType = this.nodeType as INodeTypeDescription | null;
if (!nodeType) {
return '';
}
@ -45,7 +49,18 @@ export default mixins(
return 'https://docs.n8n.io/nodes/' + (nodeType.documentationUrl || nodeType.name) + '?utm_source=n8n_app&utm_medium=node_settings_modal-credential_link&utm_campaign=' + nodeType.name;
}
return '';
return this.isCommunityNode ? `${NPM_PACKAGE_DOCS_BASE_URL}${nodeType.name.split('.')[0]}` : '';
},
isCommunityNode(): boolean {
const nodeType = this.nodeType as INodeTypeDescription | null;
if (nodeType) {
return isCommunityPackageName(nodeType.name);
}
return false;
},
packageName(): string {
const nodeType = this.nodeType as INodeTypeDescription;
return nodeType.name.split('.')[0];
},
options (): ITab[] {
const options: ITab[] = [
@ -61,11 +76,26 @@ export default mixins(
href: this.documentationUrl,
});
}
if (this.isCommunityNode) {
options.push({
icon: 'cube',
value: 'communityNode',
align: 'right',
tooltip: this.$locale.baseText('generic.communityNode.tooltip', {
interpolate: {
docUrl: COMMUNITY_NODES_INSTALLATION_DOCS_URL,
packageName: this.packageName,
},
}),
});
}
// If both tabs have align right, both will have excessive left margin
const pushCogRight = this.isCommunityNode ? false : true;
options.push(
{
icon: 'cog',
value: 'settings',
align: 'right',
align: pushCogRight ? 'right': undefined,
},
);
@ -93,6 +123,23 @@ export default mixins(
this.$emit('input', tab);
}
},
onTooltipClick(tab: string, event: MouseEvent) {
if (tab === 'communityNode' && (event.target as Element).localName === 'a') {
this.$telemetry.track('user clicked cnr docs link', { source: 'node details view' });
}
},
},
});
</script>
<style lang="scss">
#communityNode > div {
cursor: auto;
&:hover {
color: unset;
}
}
</style>

View file

@ -25,6 +25,12 @@
</i>
<span slot="title">{{ $locale.baseText('settings.n8napi') }}</span>
</n8n-menu-item>
<n8n-menu-item index="/settings/community-nodes" v-if="canAccessCommunityNodes()" :class="$style.tab">
<i :class="$style.icon">
<font-awesome-icon icon="cube" />
</i>
<span slot="title">{{ $locale.baseText('settings.communityNodes') }}</span>
</n8n-menu-item>
</n8n-menu>
<div :class="$style.versionContainer">
<n8n-link @click="onVersionClick" size="small">
@ -54,6 +60,9 @@ export default mixins(
canAccessUsersSettings(): boolean {
return this.canUserAccessRouteByName(VIEWS.USERS_SETTINGS);
},
canAccessCommunityNodes(): boolean {
return this.canUserAccessRouteByName(VIEWS.COMMUNITY_NODES);
},
canAccessApiSettings(): boolean {
return this.canUserAccessRouteByName(VIEWS.API_SETTINGS);
},
@ -102,9 +111,8 @@ export default mixins(
}
.icon {
width: 24px;
width: 16px;
display: inline-flex;
justify-content: center;
margin-right: 10px;
}

View file

@ -6,6 +6,8 @@ import { INodeTypeDescription } from 'n8n-workflow';
const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
const SI_SYMBOL = ['', 'k', 'M', 'G', 'T', 'P', 'E'];
const COMMUNITY_PACKAGE_NAME_REGEX = /(@\w+\/)?n8n-nodes-(?!base\b)\b\w+/g;
export function abbreviateNumber(num: number) {
const tier = (Math.log10(Math.abs(num)) / 3) | 0;
@ -69,6 +71,14 @@ export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
export function isCommunityPackageName(packageName: string): boolean {
COMMUNITY_PACKAGE_NAME_REGEX.lastIndex = 0;
// Community packages names start with <@username/>n8n-nodes- not followed by word 'base'
const nameMatch = COMMUNITY_PACKAGE_NAME_REGEX.exec(packageName);
return !!nameMatch;
}
export function shorten(s: string, limit: number, keep: number) {
if (s.length <= limit) {
return s;

View file

@ -15,6 +15,10 @@ import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import {
INodeTypeNameVersion,
} from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { getTriggerNodeServiceName } from '../helpers';
@ -365,6 +369,30 @@ export const pushConnection = mixins(
}
this.processWaitingPushMessages();
} else if (receivedData.type === 'reloadNodeType') {
const pushData = receivedData.data;
const nodesToBeFetched: INodeTypeNameVersion[] = [pushData];
// Force reload of all credential types
this.$store.dispatch('credentials/fetchCredentialTypes', true)
.then(() => {
// Get the data of the node and update in internal storage
return this.restApi().getNodesInformation(nodesToBeFetched);
})
.then((nodesInfo) => {
this.$store.commit('updateNodeTypes', nodesInfo);
});
} else if (receivedData.type === 'removeNodeType') {
const pushData = receivedData.data;
const nodesToBeRemoved: INodeTypeNameVersion[] = [pushData];
// Force reload of all credential types
this.$store.dispatch('credentials/fetchCredentialTypes')
.then(() => {
this.$store.commit('removeNodeTypes', nodesToBeRemoved);
});
}
return true;
},

View file

@ -18,14 +18,11 @@ export const userHelpers = Vue.extend({
canUserAccessRoute(route: Route): boolean {
const permissions: IPermissions = route.meta && route.meta.permissions;
const currentUser = this.$store.getters['users/currentUser'];
const isUMEnabled = this.$store.getters['settings/isUserManagementEnabled'];
const isPublicApiEnabled = this.$store.getters['settings/isPublicApiEnabled'];
return permissions && isAuthorized(permissions, {
currentUser,
isUMEnabled,
isPublicApiEnabled,
});
if (permissions && isAuthorized(permissions, currentUser)) {
return true;
}
return false;
},
},
});

View file

@ -38,6 +38,14 @@ export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey';
export const EXECUTIONS_MODAL_KEY = 'executions';
export const WORKFLOW_ACTIVE_MODAL_KEY = 'activation';
export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall';
export const COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY = 'communityPackageManageConfirm';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
UNINSTALL: 'uninstall',
UPDATE: 'update',
VIEW_DOCS: 'view-documentation',
};
// breakpoints
export const BREAKPOINT_SM = 768;
@ -47,6 +55,14 @@ export const BREAKPOINT_XL = 1920;
export const N8N_IO_BASE_URL = `https://api.n8n.io/api/`;
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 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/`;
// node types
export const BAMBOO_HR_NODE_TYPE = 'n8n-nodes-base.bambooHr';
@ -243,6 +259,7 @@ export enum VIEWS {
PERSONAL_SETTINGS = "PersonalSettings",
API_SETTINGS = "APISettings",
NOT_FOUND = "NotFoundView",
COMMUNITY_NODES = "CommunityNodes",
}
export const MAPPING_PARAMS = [`$evaluateExpression`, `$item`, `$jmespath`, `$node`, `$binary`, `$data`, `$env`, `$json`, `$now`, `$parameters`, `$position`, `$resumeWebhookUrl`, `$runIndex`, `$today`, `$workflow`, '$parameter'];

View file

@ -0,0 +1,87 @@
import { getInstalledCommunityNodes, installNewPackage, uninstallPackage, updatePackage } from '@/api/communityNodes';
import { getAvailableCommunityPackageCount } from '@/api/settings';
import { ICommunityNodesState, ICommunityPackageMap, IRootState } from '@/Interface';
import { PublicInstalledPackage } from 'n8n-workflow';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
const LOADER_DELAY = 300;
const module: Module<ICommunityNodesState, IRootState> = {
namespaced: true,
state: {
// -1 means that package count has not been fetched yet
availablePackageCount: -1,
installedPackages: {},
},
mutations: {
setAvailablePackageCount: (state: ICommunityNodesState, count: number) => {
state.availablePackageCount = count;
},
setInstalledPackages: (state: ICommunityNodesState, packages: PublicInstalledPackage[]) => {
state.installedPackages = packages.reduce((packageMap: ICommunityPackageMap, pack: PublicInstalledPackage) => {
packageMap[pack.packageName] = pack;
return packageMap;
}, {});
},
removePackageByName(state: ICommunityNodesState, name: string) {
Vue.delete(state.installedPackages, name);
},
updatePackageObject(state: ICommunityNodesState, newPackage: PublicInstalledPackage) {
state.installedPackages[newPackage.packageName] = newPackage;
},
},
getters: {
availablePackageCount(state: ICommunityNodesState): number {
return state.availablePackageCount;
},
getInstalledPackages(state: ICommunityNodesState): PublicInstalledPackage[] {
return Object.values(state.installedPackages).sort((a, b) => a.packageName.localeCompare(b.packageName));
},
getInstalledPackageByName(state: ICommunityNodesState) {
return (name: string) => state.installedPackages[name];
},
},
actions: {
async fetchAvailableCommunityPackageCount(context: ActionContext<ICommunityNodesState, IRootState>) {
if(context.state.availablePackageCount === -1) {
const packageCount = await getAvailableCommunityPackageCount();
context.commit('setAvailablePackageCount', packageCount);
}
},
async fetchInstalledPackages(context: ActionContext<ICommunityNodesState, IRootState>) {
const installedPackages = await getInstalledCommunityNodes(context.rootGetters.getRestApiContext);
context.commit('setInstalledPackages', installedPackages);
const timeout = installedPackages.length > 0 ? 0 : LOADER_DELAY;
setTimeout(() => {
}, timeout);
},
async installPackage(context: ActionContext<ICommunityNodesState, IRootState>, packageName: string) {
try {
await installNewPackage(context.rootGetters.getRestApiContext, packageName);
await context.dispatch('communityNodes/fetchInstalledPackages');
} catch(error) {
throw(error);
}
},
async uninstallPackage(context: ActionContext<ICommunityNodesState, IRootState>, packageName: string) {
try {
await uninstallPackage(context.rootGetters.getRestApiContext, packageName);
context.commit('removePackageByName', packageName);
} catch(error) {
throw(error);
}
},
async updatePackage(context: ActionContext<ICommunityNodesState, IRootState>, packageName: string) {
try {
const packageToUpdate = context.getters.getInstalledPackageByName(packageName);
const updatedPackage = await updatePackage(context.rootGetters.getRestApiContext, packageToUpdate.packageName);
context.commit('updatePackageObject', updatedPackage);
} catch (error) {
throw(error);
}
},
},
};
export default module;

View file

@ -152,8 +152,8 @@ const module: Module<ICredentialsState, IRootState> = {
},
},
actions: {
fetchCredentialTypes: async (context: ActionContext<ICredentialsState, IRootState>) => {
if (context.getters.allCredentialTypes.length > 0) {
fetchCredentialTypes: async (context: ActionContext<ICredentialsState, IRootState>, forceFetch: boolean) => {
if (context.getters.allCredentialTypes.length > 0 && forceFetch !== true) {
return;
}
const credentialTypes = await getCredentialTypes(context.rootGetters.getRestApiContext);

View file

@ -83,6 +83,12 @@ const module: Module<ISettingsState, IRootState> = {
templatesHost: (state): string => {
return state.settings.templates.host;
},
isCommunityNodesFeatureEnabled: (state): boolean => {
return state.settings.communityNodesEnabled;
},
isQueueModeEnabled: (state): boolean => {
return state.settings.executionMode === 'queue';
},
},
mutations: {
setSettings(state: ISettingsState, settings: IN8nUISettings) {
@ -103,6 +109,9 @@ const module: Module<ISettingsState, IRootState> = {
setTemplatesEndpointHealthy(state: ISettingsState) {
state.templatesEndpointHealthy = true;
},
setCommunityNodesFeatureEnabled(state: ISettingsState, isEnabled: boolean) {
state.settings.communityNodesEnabled = isEnabled;
},
},
actions: {
async getSettings(context: ActionContext<ISettingsState, IRootState>) {
@ -125,6 +134,7 @@ const module: Module<ISettingsState, IRootState> = {
context.commit('setN8nMetadata', settings.n8nMetadata || {}, {root: true});
context.commit('setDefaultLocale', settings.defaultLocale, {root: true});
context.commit('versions/setVersionNotificationSettings', settings.versionNotifications, {root: true});
context.commit('setCommunityNodesFeatureEnabled', settings.communityNodesEnabled === true);
},
async fetchPromptsData(context: ActionContext<ISettingsState, IRootState>) {
if (!context.getters.isTelemetryEnabled) {

View file

@ -1,5 +1,7 @@
import {
ABOUT_MODAL_KEY,
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
@ -17,6 +19,7 @@ import {
WORKFLOW_OPEN_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
VIEWS,
COMMUNITY_PACKAGE_MANAGE_ACTIONS,
} from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
@ -85,6 +88,14 @@ const module: Module<IUiState, IRootState> = {
[WORKFLOW_ACTIVE_MODAL_KEY]: {
open: false,
},
[COMMUNITY_PACKAGE_INSTALL_MODAL_KEY]: {
open: false,
},
[COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: {
open: false,
mode: '',
activeId: null,
},
},
modalStack: [],
sidebarMenuCollapsed: true,
@ -242,6 +253,16 @@ const module: Module<IUiState, IRootState> = {
context.commit('setMode', { name: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' });
context.commit('openModal', CREDENTIAL_EDIT_MODAL_KEY);
},
async openCommunityPackageUninstallConfirmModal(context: ActionContext<IUiState, IRootState>, packageName: string) {
context.commit('setActiveId', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, id: packageName});
context.commit('setMode', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, mode: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL });
context.commit('openModal', COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY);
},
async openCommunityPackageUpdateConfirmModal(context: ActionContext<IUiState, IRootState>, packageName: string) {
context.commit('setActiveId', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, id: packageName});
context.commit('setMode', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, mode: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UPDATE });
context.commit('openModal', COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY);
},
},
};

View file

@ -42,25 +42,19 @@ export const PERMISSIONS: IUserPermissions = {
},
};
interface IsAuthorizedOptions {
currentUser: IUser | null;
isUMEnabled?: boolean;
isPublicApiEnabled?: boolean;
}
export const isAuthorized = (permissions: IPermissions, {
currentUser,
isUMEnabled,
isPublicApiEnabled,
}: IsAuthorizedOptions): boolean => {
/**
* To be authorized, user must pass all deny rules and pass any of the allow rules.
*
* @param permissions
* @param currentUser
* @returns
*/
export const isAuthorized = (permissions: IPermissions, currentUser: IUser | null): boolean => {
const loginStatus = currentUser ? LOGIN_STATUS.LoggedIn : LOGIN_STATUS.LoggedOut;
// big AND block
// if any of these are false, block user
if (permissions.deny) {
if (permissions.deny.hasOwnProperty('um') && permissions.deny.um === isUMEnabled) {
return false;
}
if (permissions.deny.hasOwnProperty('api') && permissions.deny.api === isPublicApiEnabled) {
if (permissions.deny.shouldDeny && permissions.deny.shouldDeny()) {
return false;
}
@ -69,7 +63,7 @@ export const isAuthorized = (permissions: IPermissions, {
}
if (currentUser && currentUser.globalRole) {
const role = currentUser.isDefaultUser ? ROLE.Default: currentUser.globalRole.name;
const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name;
if (permissions.deny.role && permissions.deny.role.includes(role)) {
return false;
}
@ -79,12 +73,10 @@ export const isAuthorized = (permissions: IPermissions, {
}
}
// big OR block
// if any of these are true, allow user
if (permissions.allow) {
if (permissions.allow.hasOwnProperty('um') && permissions.allow.um === isUMEnabled) {
return true;
}
if (permissions.allow.hasOwnProperty('api') && permissions.allow.api === isPublicApiEnabled) {
if (permissions.allow.shouldAllow && permissions.allow.shouldAllow()) {
return true;
}
@ -93,7 +85,7 @@ export const isAuthorized = (permissions: IPermissions, {
}
if (currentUser && currentUser.globalRole) {
const role = currentUser.isDefaultUser ? ROLE.Default: currentUser.globalRole.name;
const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name;
if (permissions.allow.role && permissions.allow.role.includes(role)) {
return true;
}

View file

@ -98,21 +98,18 @@ const module: Module<IUsersState, IRootState> = {
},
canUserDeleteTags(state: IUsersState, getters: any, rootState: IRootState, rootGetters: any) { // tslint:disable-line:no-any
const currentUser = getters.currentUser;
const isUMEnabled = rootGetters['settings/isUserManagementEnabled'];
return isAuthorized(PERMISSIONS.TAGS.CAN_DELETE_TAGS, { currentUser, isUMEnabled });
return isAuthorized(PERMISSIONS.TAGS.CAN_DELETE_TAGS, currentUser);
},
canUserAccessSidebarUserInfo(state: IUsersState, getters: any, rootState: IRootState, rootGetters: any) { // tslint:disable-line:no-any
const currentUser = getters.currentUser;
const isUMEnabled = rootGetters['settings/isUserManagementEnabled'];
return isAuthorized(PERMISSIONS.PRIMARY_MENU.CAN_ACCESS_USER_INFO, { currentUser, isUMEnabled });
return isAuthorized(PERMISSIONS.PRIMARY_MENU.CAN_ACCESS_USER_INFO, currentUser);
},
showUMSetupWarning(state: IUsersState, getters: any, rootState: IRootState, rootGetters: any) { // tslint:disable-line:no-any
const currentUser = getters.currentUser;
const isUMEnabled = rootGetters['settings/isUserManagementEnabled'];
return isAuthorized(PERMISSIONS.USER_SETTINGS.VIEW_UM_SETUP_WARNING, { currentUser, isUMEnabled });
return isAuthorized(PERMISSIONS.USER_SETTINGS.VIEW_UM_SETUP_WARNING, currentUser);
},
personalizedNodeTypes(state: IUsersState, getters: any): string[] { // tslint:disable-line:no-any
const user = getters.currentUser as IUser | null;

View file

@ -2,11 +2,16 @@
"_reusableBaseText.cancel": "Cancel",
"_reusableBaseText.name": "Name",
"_reusableBaseText.save": "Save",
"_reusableDynamicText.readMore": "Read more",
"_reusableDynamicText.learnMore": "Learn more",
"_reusableDynamicText.moreInfo": "More info",
"_reusableDynamicText.oauth2.clientId": "Client ID",
"_reusableDynamicText.oauth2.clientSecret": "Client Secret",
"generic.learnMore": "Learn more",
"generic.confirm": "Confirm",
"generic.cancel": "Cancel",
"generic.communityNode": "Community Node",
"generic.communityNode.tooltip": "This is a node from our community. It's part of the {packageName} package. <a href=\"{docURL}\" target=\"_blank\" title=\"Read the n8n docs\">Learn more</a>",
"generic.delete": "Delete",
"generic.copy": "Copy",
"generic.clickToCopy": "Click to copy",
@ -445,11 +450,16 @@
"nodeSettings.notesInFlow.description": "If active, the note above will display in the flow as a subtitle",
"nodeSettings.notesInFlow.displayName": "Display Note in Flow?",
"nodeSettings.parameters": "Parameters",
"nodeSettings.communityNodeTooltip": "This is a <a href=\"{docUrl}\" target=\"_blank\"/>community node</a>",
"nodeSettings.retryOnFail.description": "If active, the node tries to execute again when it fails",
"nodeSettings.retryOnFail.displayName": "Retry On Fail",
"nodeSettings.scopes.expandedNoticeWithScopes": "<a data-key=\"toggle-expand\">{count} scope</a> available for {activeCredential} credentials<br>{scopes}<br><a data-key=\"show-less\">Show less</a> | <a data-key=\"toggle-expand\">{count} scopes</a> available for {activeCredential} credentials<br>{scopes}<br><a data-key=\"show-less\">Show less</a>",
"nodeSettings.scopes.notice": "<a data-key=\"toggle-expand\">{count} scope</a> available for {activeCredential} credentials | <a data-key=\"toggle-expand\">{count} scopes</a> available for {activeCredential} credentials",
"nodeSettings.theNodeIsNotValidAsItsTypeIsUnknown": "The node is not valid as its type ({nodeType}) is unknown",
"nodeSettings.communityNodeUnknown.title": "Install this node to use it",
"nodeSettings.communityNodeUnknown.description": "This node is not currently installed. It's part of the <a href=\"https://www.npmjs.com/package/{packageName}\" target=\"_blank\"/>{packageName}</a> community package.",
"nodeSettings.communityNodeUnknown.installLink.text": "How to install community nodes",
"nodeSettings.nodeTypeUnknown.description": "This node is not currently installed. It is either from a newer version of n8n, a <a href=\"{docURL}\" target=\"_blank\"/>custom node</a>, or has an invalid structure",
"nodeSettings.thisNodeDoesNotHaveAnyParameters": "This node does not have any parameters",
"nodeSettings.useTheHttpRequestNode": "Use the <b>HTTP Request</b> node to make a custom API call. We'll take care of the {nodeTypeDisplayName} auth for you. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/custom-operations/\">Learn more</a>",
"nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)",
@ -658,6 +668,46 @@
"saveButton.saved": "Saved",
"saveButton.saving": "Saving",
"settings": "Settings",
"settings.communityNodes": "Community nodes",
"settings.communityNodes.empty.title": "Supercharge your workflows with community nodes",
"settings.communityNodes.empty.description": "Install over {count} node packages contributed by our community. <br /><a href=\"{docURL}\" target=\"_blank\" title=\"Read the n8n docs\">More info</a>",
"settings.communityNodes.empty.description.no-packages": "Install node packages contributed by our community. <br /><a href=\"{docURL}\" target=\"_blank\" title=\"Read the n8n docs\">More info</a>",
"settings.communityNodes.empty.installPackageLabel": "Install a community node",
"settings.communityNodes.queueMode.warning": "You need to install community nodes manually because your instance is running in queue mode. <a href=\"{docURL}\" target=\"_blank\" title=\"Read the n8n docs\">More info</a>",
"settings.communityNodes.packageNodes.label": "{count} node | {count} nodes",
"settings.communityNodes.updateAvailable.tooltip": "A newer version is available",
"settings.communityNodes.viewDocsAction.label": "Documentation",
"settings.communityNodes.uninstallAction.label": "Uninstall package",
"settings.communityNodes.upToDate.tooltip": "You are up to date",
"settings.communityNodes.failedToLoad.tooltip": "There is a problem with this package, try uninstalling it then reinstalling to resolve this issue",
"settings.communityNodes.fetchError.title": "Problem fetching installed packages",
"settings.communityNodes.fetchError.message": "There may be a problem with your internet connection or your n8n instance",
"settings.communityNodes.installModal.title": "Install community nodes",
"settings.communityNodes.installModal.description": "Find community nodes to add on the npm public registry.",
"settings.communityNodes.browseButton.label": "Browse",
"settings.communityNodes.installModal.packageName.label": "npm Package Name",
"settings.communityNodes.installModal.packageName.tooltip": "<img src='/static/community_package_tooltip_img.png'/><p>This is the title of the package on <a href='{npmURL}'>npmjs.com</a></p><p>Install a specific version by adding it after @, e.g. <code>package-name@0.15.0</code></p>",
"settings.communityNodes.installModal.packageName.placeholder": "e.g. n8n-nodes-chatwork",
"settings.communityNodes.installModal.checkbox.label": "I understand the risks of installing unverified code from a public source.",
"settings.communityNodes.installModal.installButton.label": "Install",
"settings.communityNodes.installModal.installButton.label.loading": "Installing",
"settings.communityNodes.installModal.error.packageNameNotValid": "Package name must start with n8n-nodes-",
"settings.communityNodes.messages.install.success": "Package installed",
"settings.communityNodes.messages.install.error": "Error installing new package",
"settings.communityNodes.messages.uninstall.error": "Problem uninstalling package",
"settings.communityNodes.messages.uninstall.success.title": "Package uninstalled",
"settings.communityNodes.messages.update.success.title": "Package updated",
"settings.communityNodes.messages.update.success.message": "{packageName} updated to version {version}",
"settings.communityNodes.messages.update.error.title": "Problem updating package",
"settings.communityNodes.confirmModal.uninstall.title": "Uninstall package?",
"settings.communityNodes.confirmModal.uninstall.message": "Any workflows that use nodes from the {packageName} package won't be able to run. Are you sure?",
"settings.communityNodes.confirmModal.uninstall.buttonLabel": "Uninstall package",
"settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel": "Uninstalling",
"settings.communityNodes.confirmModal.update.title": "Update community node package?",
"settings.communityNodes.confirmModal.update.message": "You are about to update {packageName} to version {version}",
"settings.communityNodes.confirmModal.update.description": "We recommend you deactivate workflows that use any of the package's nodes and reactivate them once the update is completed",
"settings.communityNodes.confirmModal.update.buttonLabel": "Update package",
"settings.communityNodes.confirmModal.update.buttonLoadingLabel": "Updating...",
"settings.goBack": "Go back",
"settings.personal": "Personal",
"settings.personal.basicInformation": "Basic Information",

View file

@ -28,6 +28,7 @@ import {
faCloud,
faCloudDownloadAlt,
faCopy,
faCube,
faCut,
faDotCircle,
faEdit,
@ -92,6 +93,7 @@ import {
faTerminal,
faThLarge,
faTimes,
faTimesCircle,
faTrash,
faUndo,
faUnlink,
@ -136,6 +138,7 @@ addIcon(faClone);
addIcon(faCloud);
addIcon(faCloudDownloadAlt);
addIcon(faCopy);
addIcon(faCube);
addIcon(faCut);
addIcon(faDotCircle);
addIcon(faGripVertical);
@ -202,6 +205,7 @@ addIcon(faTasks);
addIcon(faTerminal);
addIcon(faThLarge);
addIcon(faTimes);
addIcon(faTimesCircle);
addIcon(faTrash);
addIcon(faUndo);
addIcon(faUnlink);

View file

@ -8,6 +8,7 @@ import MainSidebar from '@/components/MainSidebar.vue';
import NodeView from '@/views/NodeView.vue';
import SettingsPersonalView from './views/SettingsPersonalView.vue';
import SettingsUsersView from './views/SettingsUsersView.vue';
import SettingsCommunityNodesView from './views/SettingsCommunityNodesView.vue';
import SettingsApiView from './views/SettingsApiView.vue';
import SetupView from './views/SetupView.vue';
import SigninView from './views/SigninView.vue';
@ -22,6 +23,7 @@ import { IPermissions, IRootState } from './Interface';
import { LOGIN_STATUS, ROLE } from './modules/userHelpers';
import { RouteConfigSingleView } from 'vue-router/types/router';
import { VIEWS } from './constants';
import { store } from './store';
Vue.use(Router);
@ -272,7 +274,9 @@ const router = new Router({
role: [ROLE.Default],
},
deny: {
um: false,
shouldDeny: () => {
return store.getters['settings/isUserManagementEnabled'] === false;
},
},
},
},
@ -330,7 +334,9 @@ const router = new Router({
role: [ROLE.Default, ROLE.Owner],
},
deny: {
um: false,
shouldDeny: () => {
return store.getters['settings/isUserManagementEnabled'] === false;
},
},
},
},
@ -370,7 +376,31 @@ const router = new Router({
loginStatus: [LOGIN_STATUS.LoggedIn],
},
deny: {
api: false,
shouldDeny: () => {
return store.getters['settings/isPublicApiEnabled'] === false;
},
},
},
},
},
{
path: '/settings/community-nodes',
name: VIEWS.COMMUNITY_NODES,
components: {
default: SettingsCommunityNodesView,
},
meta: {
telemetry: {
pageCategory: 'settings',
},
permissions: {
allow: {
role: [ROLE.Default, ROLE.Owner],
},
deny: {
shouldDeny: () => {
return store.getters['settings/isCommunityNodesFeatureEnabled'] === false;
},
},
},
},

View file

@ -29,6 +29,7 @@ import {
IWorkflowDb,
XYPosition,
IRestApiContext,
ICommunityNodesState,
} from './Interface';
import credentials from './modules/credentials';
@ -39,6 +40,8 @@ import users from './modules/users';
import workflows from './modules/workflows';
import versions from './modules/versions';
import templates from './modules/templates';
import communityNodes from './modules/communityNodes';
import { isCommunityPackageName } from './components/helpers';
Vue.use(Vuex);
@ -102,6 +105,7 @@ const modules = {
versions,
users,
ui,
communityNodes,
};
export const store = new Vuex.Store({
@ -503,11 +507,9 @@ export const store = new Vuex.Store({
state.nodeViewOffsetPosition = data.newOffset;
},
// Node-Types
setNodeTypes (state, nodeTypes: INodeTypeDescription[]) {
Vue.set(state, 'nodeTypes', nodeTypes);
},
// Active Execution
setExecutingNode (state, executingNode: string) {
state.executingNode = executingNode;
@ -652,10 +654,18 @@ export const store = new Vuex.Store({
updateNodeTypes (state, nodeTypes: INodeTypeDescription[]) {
const oldNodesToKeep = state.nodeTypes.filter(node => !nodeTypes.find(n => n.name === node.name && n.version.toString() === node.version.toString()));
const newNodesState = [...oldNodesToKeep, ...nodeTypes];
Vue.set(state, 'nodeTypes', newNodesState);
state.nodeTypes = newNodesState;
},
removeNodeTypes (state, nodeTypes: INodeTypeDescription[]) {
console.log('Store will remove nodes: ', nodeTypes); // eslint-disable-line no-console
const oldNodesToKeep = state.nodeTypes.filter(node => !nodeTypes.find(n => n.name === node.name && n.version === node.version));
Vue.set(state, 'nodeTypes', oldNodesToKeep);
state.nodeTypes = oldNodesToKeep;
},
addSidebarMenuItems (state, menuItems: IMenuItem[]) {
const updated = state.sidebarMenuItems.concat(menuItems);
Vue.set(state, 'sidebarMenuItems', updated);
@ -843,7 +853,6 @@ export const store = new Vuex.Store({
allNodeTypes: (state): INodeTypeDescription[] => {
return state.nodeTypes;
},
/**
* Getter for node default names ending with a number: `'S3'`, `'Magento 2'`, etc.
*/

View file

@ -2866,11 +2866,14 @@ export default mixins(
this.$store.commit('setNodeTypes', nodeTypes);
},
async loadCredentialTypes (): Promise<void> {
await this.$store.dispatch('credentials/fetchCredentialTypes');
await this.$store.dispatch('credentials/fetchCredentialTypes', true);
},
async loadCredentials (): Promise<void> {
await this.$store.dispatch('credentials/fetchAllCredentials');
},
async loadCommunityNodes (): Promise<void> {
await this.$store.dispatch('communityNodes/fetchInstalledPackages');
},
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
const allNodes:INodeTypeDescription[] = this.$store.getters.allNodeTypes;
@ -2953,6 +2956,7 @@ export default mixins(
this.loadCredentials(),
this.loadCredentialTypes(),
this.loadNodeTypes(),
this.loadCommunityNodes(),
];
try {

View file

@ -0,0 +1,199 @@
<template>
<SettingsView>
<div :class="$style.container">
<div :class="$style.headingContainer">
<n8n-heading size="2xlarge">{{ $locale.baseText('settings.communityNodes') }}</n8n-heading>
<n8n-button
v-if="!isQueueModeEnabled && getInstalledPackages.length > 0 && !loading"
:label="$locale.baseText('settings.communityNodes.installModal.installButton.label')"
size="large"
@click="openInstallModal"
/>
</div>
<n8n-action-box
v-if="isQueueModeEnabled"
:heading="$locale.baseText('settings.communityNodes.empty.title')"
:description="getEmptyStateDescription"
:calloutText="actionBoxConfig.calloutText"
:calloutTheme="actionBoxConfig.calloutTheme"
@click="openInstallModal"
@descriptionClick="onDescriptionTextClick"
/>
<div
:class="$style.cardsContainer"
v-else-if="loading"
>
<community-package-card
v-for="n in 2"
:key="'index-' + n"
:loading="true"
></community-package-card>
</div>
<div
v-else-if="getInstalledPackages.length === 0"
:class="$style.actionBoxContainer"
>
<n8n-action-box
:heading="$locale.baseText('settings.communityNodes.empty.title')"
:description="getEmptyStateDescription"
:buttonText="$locale.baseText('settings.communityNodes.empty.installPackageLabel')"
:calloutText="actionBoxConfig.calloutText"
:calloutTheme="actionBoxConfig.calloutTheme"
@click="openInstallModal"
@descriptionClick="onDescriptionTextClick"
/>
</div>
<div
:class="$style.cardsContainer"
v-else
>
<community-package-card
v-for="communityPackage in getInstalledPackages"
:key="communityPackage.packageName"
:communityPackage="communityPackage"
></community-package-card>
</div>
</div>
</SettingsView>
</template>
<script lang="ts">
import { mapGetters } from 'vuex';
import SettingsView from './SettingsView.vue';
import CommunityPackageCard from '../components/CommunityPackageCard.vue';
import { showMessage } from '@/components/mixins/showMessage';
import mixins from 'vue-typed-mixins';
import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '../constants';
import { PublicInstalledPackage } from 'n8n-workflow';
const PACKAGE_COUNT_THRESHOLD = 31;
export default mixins(
showMessage,
).extend({
name: 'SettingsCommunityNodesView',
components: {
SettingsView,
CommunityPackageCard,
},
data () {
return {
loading: false,
};
},
async mounted() {
try {
this.$data.loading = true;
await this.$store.dispatch('communityNodes/fetchInstalledPackages');
const installedPackages: PublicInstalledPackage[] = this.getInstalledPackages;
const packagesToUpdate: PublicInstalledPackage[] = installedPackages.filter(p => p.updateAvailable );
this.$telemetry.track('user viewed cnr settings page', {
num_of_packages_installed: installedPackages.length,
installed_packages: installedPackages.map(p => {
return {
package_name: p.packageName,
package_version: p.installedVersion,
package_nodes: p.installedNodes.map(node => `${node.name}-v${node.latestVersion}`),
is_update_available: p.updateAvailable !== undefined,
};
}),
packages_to_update: packagesToUpdate.map(p => {
return {
package_name: p.packageName,
package_version_current: p.installedVersion,
package_version_available: p.updateAvailable,
};
}),
number_of_updates_available: packagesToUpdate.length,
});
} catch (error) {
this.$showError(
error,
this.$locale.baseText('settings.communityNodes.fetchError.title'),
this.$locale.baseText('settings.communityNodes.fetchError.message'),
);
} finally {
this.$data.loading = false;
}
try {
await this.$store.dispatch('communityNodes/fetchAvailableCommunityPackageCount');
} finally {
this.$data.loading = false;
}
},
computed: {
...mapGetters('settings', ['isQueueModeEnabled']),
...mapGetters('communityNodes', ['getInstalledPackages']),
getEmptyStateDescription() {
const packageCount = this.$store.getters['communityNodes/availablePackageCount'];
return packageCount < PACKAGE_COUNT_THRESHOLD ?
this.$locale.baseText('settings.communityNodes.empty.description.no-packages', {
interpolate: {
docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL,
},
}) :
this.$locale.baseText('settings.communityNodes.empty.description', {
interpolate: {
docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL,
count: (Math.floor(packageCount / 10) * 10).toString(),
},
});
},
actionBoxConfig() {
return this.isQueueModeEnabled ? {
calloutText: this.$locale.baseText('settings.communityNodes.queueMode.warning', {
interpolate: { docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL },
}),
calloutTheme: 'warning',
hideButton: true,
} : {
calloutText: '',
calloutTheme: '',
hideButton: false,
};
},
},
methods: {
openInstallModal(event: MouseEvent) {
this.$telemetry.track('user clicked cnr install button', { is_empty_state: this.getInstalledPackages.length === 0 });
this.$store.dispatch('ui/openModal', COMMUNITY_PACKAGE_INSTALL_MODAL_KEY);
},
onDescriptionTextClick(event: MouseEvent) {
if ((event.target as Element).localName === 'a') {
this.$telemetry.track('user clicked cnr learn more link', { source: 'cnr settings page' });
}
},
},
});
</script>
<style lang="scss" module>
.container {
height: 100%;
padding-right: var(--spacing-2xs);
> * {
margin-bottom: var(--spacing-2xl);
}
}
.headingContainer {
display: flex;
justify-content: space-between;
}
.loadingContainer {
display: flex;
gap: var(--spacing-xs);
}
.actionBoxContainer {
text-align: center;
}
.cardsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
}
</style>

View file

@ -1588,3 +1588,22 @@ export interface IOAuth2Credentials {
scope: string;
oauthTokenData?: IDataObject;
}
export type PublicInstalledPackage = {
packageName: string;
installedVersion: string;
authorName?: string;
authorEmail?: string;
installedNodes: PublicInstalledNode[];
createdAt: Date;
updatedAt: Date;
updateAvailable?: string;
failedLoading?: boolean;
};
export type PublicInstalledNode = {
name: string;
type: string;
latestVersion: string;
package: PublicInstalledPackage;
};

View file

@ -109,7 +109,20 @@ export class RoutingNode {
if (credentialsDecrypted) {
credentials = credentialsDecrypted.data;
} else if (credentialType) {
credentials = (await executeFunctions.getCredentials(credentialType)) || {};
try {
credentials = (await executeFunctions.getCredentials(credentialType)) || {};
} catch (error) {
if (
nodeType.description.credentials?.length &&
nodeType.description.credentials[0].required
) {
// Only throw error if credential is mandatory
throw error;
} else {
// Do not request cred type since it doesn't exist
credentialType = undefined;
}
}
}
// TODO: Think about how batching could be handled for REST APIs which support it

View file

@ -909,7 +909,7 @@ export class Workflow {
nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType;
if (nodeType.trigger !== undefined || nodeType.poll !== undefined) {
if (nodeType && (nodeType.trigger !== undefined || nodeType.poll !== undefined)) {
if (node.disabled === true) {
continue;
}