mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-23 11:44:06 -08:00
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:
parent
a02b206170
commit
c85faff4f1
|
@ -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');
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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",
|
||||
|
|
218
packages/cli/src/CommunityNodes/helpers.ts
Normal file
218
packages/cli/src/CommunityNodes/helpers.ts
Normal 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
|
||||
}
|
||||
}
|
75
packages/cli/src/CommunityNodes/packageModel.ts
Normal file
75
packages/cli/src/CommunityNodes/packageModel.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
// ----------------------------------
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
// ----------------------------------------
|
||||
|
|
316
packages/cli/src/api/nodes.api.ts
Normal file
316
packages/cli/src/api/nodes.api.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
|
@ -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';
|
||||
|
|
22
packages/cli/src/databases/entities/InstalledNodes.ts
Normal file
22
packages/cli/src/databases/entities/InstalledNodes.ts
Normal 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;
|
||||
}
|
67
packages/cli/src/databases/entities/InstalledPackages.ts
Normal file
67
packages/cli/src/databases/entities/InstalledPackages.ts
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
14
packages/cli/src/requests.d.ts
vendored
14
packages/cli/src/requests.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
|
341
packages/cli/test/integration/nodes.api.test.ts
Normal file
341
packages/cli/test/integration/nodes.api.test.ts
Normal 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;
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
|
|
16
packages/cli/test/integration/shared/types.d.ts
vendored
16
packages/cli/test/integration/shared/types.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
331
packages/cli/test/unit/CommunityNodeHelpers.test.ts
Normal file
331
packages/cli/test/unit/CommunityNodeHelpers.test.ts
Normal 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];
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>',
|
||||
};
|
103
packages/design-system/src/components/N8nCallout/Callout.vue
Normal file
103
packages/design-system/src/components/N8nCallout/Callout.vue
Normal 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>
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>"
|
||||
`;
|
|
@ -0,0 +1,3 @@
|
|||
import N8nCallout from './Callout.vue';
|
||||
|
||||
export default N8nCallout;
|
|
@ -10,7 +10,7 @@ export default {
|
|||
size: {
|
||||
control: {
|
||||
type: 'select',
|
||||
options: ['small', 'medium', 'large'],
|
||||
options: ['xsmall', 'small', 'medium', 'large'],
|
||||
},
|
||||
},
|
||||
spin: {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 |
|
@ -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;
|
||||
}
|
||||
|
|
20
packages/editor-ui/src/api/communityNodes.ts
Normal file
20
packages/editor-ui/src/api/communityNodes.ts
Normal 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 });
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
181
packages/editor-ui/src/components/CommunityPackageCard.vue
Normal file
181
packages/editor-ui/src/components/CommunityPackageCard.vue
Normal 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,
|
||||
})
|
||||
}}:
|
||||
</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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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');
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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'];
|
||||
|
|
87
packages/editor-ui/src/modules/communityNodes.ts
Normal file
87
packages/editor-ui/src/modules/communityNodes.ts
Normal 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;
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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 {
|
||||
|
|
199
packages/editor-ui/src/views/SettingsCommunityNodesView.vue
Normal file
199
packages/editor-ui/src/views/SettingsCommunityNodesView.vue
Normal 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>
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue