Merge branch 'n8n-io:master' into Add-schema-registry-into-kafka

This commit is contained in:
Ricardo Georgel 2021-09-27 16:31:59 -03:00 committed by GitHub
commit e253523dbe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
353 changed files with 29085 additions and 5089 deletions

View file

@ -2,7 +2,6 @@
Great that you are here and you want to contribute to n8n
## Contents
- [Code of Conduct](#code-of-conduct)
@ -15,16 +14,14 @@ Great that you are here and you want to contribute to n8n
- [Extend Documentation](#extend-documentation)
- [Contributor License Agreement](#contributor-license-agreement)
## Code of Conduct
## Code of conduct
This project and everyone participating in it are governed by the Code of
Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report
unacceptable behavior to jan@n8n.io.
## Directory Structure
## Directory structure
n8n is split up in different modules which are all in a single mono repository.
@ -36,7 +33,8 @@ The most important directories:
- [/packages/cli](/packages/cli) - CLI code to run front- & backend
- [/packages/core](/packages/core) - Core code which handles workflow
execution, active webhooks and
workflows
workflows. **Contact n8n before
starting on any changes here**
- [/packages/design-system](/packages/design-system) - Vue frontend components
- [/packages/editor-ui](/packages/editor-ui) - Vue frontend workflow editor
- [/packages/node-dev](/packages/node-dev) - CLI to create new n8n-nodes
@ -44,17 +42,14 @@ The most important directories:
- [/packages/workflow](/packages/workflow) - Workflow code with interfaces which
get used by front- & backend
## Development Setup
## Development setup
If you want to change or extend n8n you have to make sure that all needed
dependencies are installed and the packages get linked correctly. Here a short guide on how that can be done:
### Requirements
#### Build Tools
#### Build tools
The packages which n8n uses depend on a few build tools:
@ -86,18 +81,23 @@ So for the setup to work correctly lerna has to be installed globally like this:
npm install -g lerna
```
### Actual n8n setup
> **IMPORTANT**: All the steps bellow have to get executed at least once to get the development setup up and running!
Now that everything n8n requires to run is installed the actual n8n code can be
checked out and set up:
1. Clone the repository
1. [Fork](https://guides.github.com/activities/forking/#fork) the n8n repository
1. Clone your forked repository
```
git clone https://github.com/n8n-io/n8n.git
git clone https://github.com/<your_github_username>/n8n.git
```
1. Add the original n8n repository as `upstream` to your forked repository
```
git remote add upstream https://github.com/n8n-io/n8n.git
```
1. Go into repository folder
@ -115,8 +115,6 @@ checked out and set up:
npm run build
```
### Start
To start n8n execute:
@ -130,7 +128,7 @@ To start n8n with tunnel:
./packages/cli/bin/n8n start --tunnel
```
## Development Cycle
## Development cycle
While iterating on n8n modules code, you can run `npm run dev`. It will then
automatically build your code, restart the backend and refresh the frontend
@ -147,12 +145,11 @@ automatically build your code, restart the backend and refresh the frontend
npm run start
```
1. Create tests
1. Run all tests
1. Run all [tests](#test-suite)
```
npm run test
```
1. Commit code and create pull request
1. Commit code and [create a pull request](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork)
### Test suite
@ -165,34 +162,26 @@ If that gets executed in one of the package folders it will only run the tests
of this package. If it gets executed in the n8n-root folder it will run all
tests of all packages.
## Create custom nodes
## Create Custom Nodes
> **IMPORTANT**: Avoid use of external libraries to ensure your custom nodes can be reviewed and merged quickly.
Learn about [using the node dev CLI](https://docs.n8n.io/nodes/creating-nodes/node-dev-cli.html) to create custom nodes for n8n.
More information can
be found in the documentation of [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev), which is a small CLI which
helps with n8n-node-development.
More information can be found in the documentation of [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev), a small CLI which helps with n8n-node-development.
## Create a new node to contribute to n8n
Follow this tutorial on [creating your first node](https://docs.n8n.io/nodes/creating-nodes/create-node.html) for n8n.
## Checklist before submitting a new node
There are several things to keep in mind when creating a node. To help you, we prepared a [checklist](https://docs.n8n.io/nodes/creating-nodes/node-review-checklist.html) that covers the requirements for creating nodes, from preparation to submission. This will help us be quicker to review and merge your PR.
## Extend Documentation
## Extend documentation
The repository for the n8n documentation on [docs.n8n.io](https://docs.n8n.io) can be found [here](https://github.com/n8n-io/n8n-docs).
## Contributor License Agreement
That we do not have any potential problems later it is sadly necessary to sign a [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). That can be done literally with the push of a button.

View file

@ -8,8 +8,8 @@
"dev": "lerna exec npm run dev --parallel",
"clean:dist": "lerna exec -- rimraf ./dist",
"format": "lerna exec npm run format",
"lint": "lerna exec npm run lint",
"lintfix": "lerna exec npm run lintfix",
"lint": "lerna exec npm run lint",
"lintfix": "lerna exec npm run lintfix",
"optimize-svg": "find ./packages -name '*.svg' ! -name 'pipedrive.svg' -print0 | xargs -0 -P16 -L20 npx svgo",
"start": "run-script-os",
"start:default": "cd packages/cli/bin && ./n8n",

View file

@ -2,6 +2,20 @@
This list shows all the versions which include breaking changes and how to upgrade.
## 0.139.0
### What changed?
For the HubSpot Trigger node, the authentication process has changed to OAuth2.
### When is action necessary?
If you are using the Hubspot Trigger.
### How to upgrade:
Create an app in HubSpot, use the Client ID, Client Secret, App ID, and the Developer Key, and complete the OAuth2 flow.
## 0.135.0
### What changed?

View file

@ -170,6 +170,7 @@ export class ExecuteBatch extends Command {
'missing a required parameter',
'insufficient credit balance',
'request timed out',
'status code 401',
];
// eslint-disable-next-line no-param-reassign

View file

@ -148,6 +148,12 @@ const config = convict({
env: 'CREDENTIALS_OVERWRITE_ENDPOINT',
},
},
defaultName: {
doc: 'Default name for credentials',
format: String,
default: 'My credentials',
env: 'CREDENTIALS_DEFAULT_NAME',
},
},
workflows: {

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.136.0",
"version": "0.139.1",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -88,7 +88,7 @@
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"body-parser": "^1.18.3",
"body-parser-xml": "^1.1.0",
"body-parser-xml": "^2.0.3",
"bull": "^3.19.0",
"callsites": "^3.1.0",
"class-validator": "^0.13.1",
@ -109,10 +109,10 @@
"localtunnel": "^2.0.0",
"lodash.get": "^4.4.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.81.0",
"n8n-editor-ui": "~0.104.0",
"n8n-nodes-base": "~0.133.0",
"n8n-workflow": "~0.66.0",
"n8n-core": "~0.84.0",
"n8n-editor-ui": "~0.107.1",
"n8n-nodes-base": "~0.136.0",
"n8n-workflow": "~0.70.0",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
"pg": "^8.3.0",

View file

@ -46,6 +46,7 @@ import {
WorkflowHelpers,
WorkflowRunner,
} from '.';
import config = require('../config');
const WEBHOOK_PROD_UNREGISTERED_HINT = `The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)`;
@ -67,8 +68,17 @@ export class ActiveWorkflowRunner {
active: true,
})) as IWorkflowDb[];
// Clear up active workflow table
await Db.collections.Webhook?.clear();
if (!config.get('endpoints.skipWebhoooksDeregistrationOnShutdown')) {
// Do not clean up database when skip registration is done.
// This flag is set when n8n is running in scaled mode.
// Impact is minimal, but for a short while, n8n will stop accepting requests.
// Also, users had issues when running multiple "main process"
// instances if many of them start at the same time
// This is not officially supported but there is no reason
// it should not work.
// Clear up active workflow table
await Db.collections.Webhook?.clear();
}
this.activeWorkflows = new ActiveWorkflows();
@ -426,6 +436,20 @@ export class ActiveWorkflowRunner {
);
}
} catch (error) {
if (
activation === 'init' &&
config.get('endpoints.skipWebhoooksDeregistrationOnShutdown') &&
error.name === 'QueryFailedError'
) {
// When skipWebhoooksDeregistrationOnShutdown is enabled,
// n8n does not remove the registered webhooks on exit.
// This means that further initializations will always fail
// when inserting to database. This is why we ignore this error
// as it's expected to happen.
// eslint-disable-next-line no-continue
continue;
}
try {
await this.removeWorkflowWebhooks(workflow.id as string);
} catch (error) {

View file

@ -30,6 +30,9 @@ const mockNodeTypes: INodeTypes = {
getByName: (nodeType: string): INodeType | undefined => {
return undefined;
},
getByNameAndVersion: (): INodeType | undefined => {
return undefined;
},
};
export class CredentialsHelper extends ICredentialsHelper {

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable no-param-reassign */
@ -11,7 +12,11 @@ import { IDataObject } from 'n8n-workflow';
import * as config from '../config';
// eslint-disable-next-line import/no-cycle
import { IPackageVersions } from '.';
import { Db, ICredentialsDb, IPackageVersions } from '.';
// eslint-disable-next-line import/order
import { Like } from 'typeorm';
// eslint-disable-next-line import/no-cycle
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
let versionCache: IPackageVersions | undefined;
@ -170,3 +175,56 @@ export function getConfigValueSync(configKey: string): string | boolean | number
return data;
}
/**
* Generate a unique name for a workflow or credentials entity.
*
* - If the name does not yet exist, it returns the requested name.
* - If the name already exists once, it returns the requested name suffixed with 2.
* - If the name already exists more than once with suffixes, it looks for the max suffix
* and returns the requested name with max suffix + 1.
*/
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export async function generateUniqueName(
requestedName: string,
entityType: 'workflow' | 'credentials',
) {
const findConditions = {
select: ['name' as const],
where: {
name: Like(`${requestedName}%`),
},
};
const found: Array<WorkflowEntity | ICredentialsDb> =
entityType === 'workflow'
? await Db.collections.Workflow!.find(findConditions)
: await Db.collections.Credentials!.find(findConditions);
// name is unique
if (found.length === 0) {
return { name: requestedName };
}
const maxSuffix = found.reduce((acc, { name }) => {
const parts = name.split(`${requestedName} `);
if (parts.length > 2) return acc;
const suffix = Number(parts[1]);
// eslint-disable-next-line no-restricted-globals
if (!isNaN(suffix) && Math.ceil(suffix) > acc) {
acc = Math.ceil(suffix);
}
return acc;
}, 0);
// name is duplicate but no numeric suffixes exist yet
if (maxSuffix === 0) {
return { name: `${requestedName} 2` };
}
return { name: `${requestedName} ${maxSuffix + 1}` };
}

View file

@ -121,6 +121,7 @@ export interface ICredentialsBase {
export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted {
id: number | string;
name: string;
}
export interface ICredentialsResponse extends ICredentialsDb {

View file

@ -15,6 +15,7 @@ import {
ILogger,
INodeType,
INodeTypeData,
INodeVersionedType,
LoggerProxy,
} from 'n8n-workflow';
@ -151,6 +152,14 @@ class LoadNodesAndCredentialsClass {
let tempCredential: ICredentialType;
try {
tempCredential = new tempModule[credentialName]() as ICredentialType;
if (tempCredential.icon && tempCredential.icon.startsWith('file:')) {
// If a file icon gets used add the full path
tempCredential.icon = `file:${path.join(
path.dirname(filePath),
tempCredential.icon.substr(5),
)}`;
}
} catch (e) {
if (e instanceof TypeError) {
throw new Error(
@ -173,13 +182,14 @@ class LoadNodesAndCredentialsClass {
* @returns {Promise<void>}
*/
async loadNodeFromFile(packageName: string, nodeName: string, filePath: string): Promise<void> {
let tempNode: INodeType;
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);
try {
tempNode = new tempModule[nodeName]() as INodeType;
tempNode = new tempModule[nodeName]();
this.addCodex({ node: tempNode, filePath, isCustom: packageName === 'CUSTOM' });
} catch (error) {
// eslint-disable-next-line no-console
@ -199,13 +209,36 @@ class LoadNodesAndCredentialsClass {
)}`;
}
if (tempNode.executeSingle) {
if (tempNode.hasOwnProperty('executeSingle')) {
this.logger.warn(
`"executeSingle" will get deprecated soon. Please update the code of node "${packageName}.${nodeName}" to use "execute" instead!`,
{ filePath },
);
}
if (tempNode.hasOwnProperty('nodeVersions')) {
const versionedNodeType = (tempNode as INodeVersionedType).getNodeType();
this.addCodex({ node: versionedNodeType, filePath, isCustom: packageName === 'CUSTOM' });
if (
versionedNodeType.description.icon !== undefined &&
versionedNodeType.description.icon.startsWith('file:')
) {
// If a file icon gets used add the full path
versionedNodeType.description.icon = `file:${path.join(
path.dirname(filePath),
versionedNodeType.description.icon.substr(5),
)}`;
}
if (versionedNodeType.hasOwnProperty('executeSingle')) {
this.logger.warn(
`"executeSingle" will get deprecated soon. Please update the code of node "${packageName}.${nodeName}" to use "execute" instead!`,
{ filePath },
);
}
}
if (this.includeNodes !== undefined && !this.includeNodes.includes(fullNodeName)) {
return;
}
@ -249,7 +282,15 @@ class LoadNodesAndCredentialsClass {
* @param obj.isCustom Whether the node is custom
* @returns {void}
*/
addCodex({ node, filePath, isCustom }: { node: INodeType; filePath: string; isCustom: boolean }) {
addCodex({
node,
filePath,
isCustom,
}: {
node: INodeType | INodeVersionedType;
filePath: string;
isCustom: boolean;
}) {
try {
const codex = this.getCodex(filePath);

View file

@ -1,4 +1,14 @@
import { INodeType, INodeTypeData, INodeTypes, NodeHelpers } from 'n8n-workflow';
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
INodeType,
INodeTypeData,
INodeTypes,
INodeVersionedType,
NodeHelpers,
} from 'n8n-workflow';
class NodeTypesClass implements INodeTypes {
nodeTypes: INodeTypeData = {};
@ -8,29 +18,30 @@ class NodeTypesClass implements INodeTypes {
// polling nodes the polling times
// eslint-disable-next-line no-restricted-syntax
for (const nodeTypeData of Object.values(nodeTypes)) {
const applyParameters = NodeHelpers.getSpecialNodeParameters(nodeTypeData.type);
const nodeType = NodeHelpers.getVersionedTypeNode(nodeTypeData.type);
const applyParameters = NodeHelpers.getSpecialNodeParameters(nodeType);
if (applyParameters.length) {
// eslint-disable-next-line prefer-spread
nodeTypeData.type.description.properties.unshift.apply(
nodeTypeData.type.description.properties,
applyParameters,
);
nodeType.description.properties.unshift(...applyParameters);
}
}
this.nodeTypes = nodeTypes;
}
getAll(): INodeType[] {
getAll(): Array<INodeType | INodeVersionedType> {
return Object.values(this.nodeTypes).map((data) => data.type);
}
getByName(nodeType: string): INodeType | undefined {
getByName(nodeType: string): INodeType | INodeVersionedType | undefined {
if (this.nodeTypes[nodeType] === undefined) {
throw new Error(`The node-type "${nodeType}" is not known!`);
}
return this.nodeTypes[nodeType].type;
}
getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version);
}
}
let nodeTypesInstance: NodeTypesClass | undefined;

View file

@ -52,24 +52,39 @@ import { createHash, createHmac } from 'crypto';
import { compare } from 'bcryptjs';
import * as promClient from 'prom-client';
import { Credentials, LoadNodeParameterOptions, UserSettings } from 'n8n-core';
import {
Credentials,
ICredentialTestFunctions,
LoadNodeParameterOptions,
NodeExecuteFunctions,
UserSettings,
} from 'n8n-core';
import {
ICredentialsDecrypted,
ICredentialsEncrypted,
ICredentialType,
IDataObject,
INodeCredentials,
INodeParameters,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
INodeTypeNameVersion,
IRunData,
INodeVersionedType,
IWorkflowBase,
IWorkflowCredentials,
LoggerProxy,
NodeCredentialTestRequest,
NodeCredentialTestResult,
NodeHelpers,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { NodeVersionedType } from 'n8n-nodes-base';
import * as basicAuth from 'basic-auth';
import * as compression from 'compression';
import * as jwt from 'jsonwebtoken';
@ -131,7 +146,7 @@ import * as config from '../config';
import * as TagHelpers from './TagHelpers';
import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { WorkflowNameRequest } from './WorkflowHelpers';
import { NameRequest } from './WorkflowHelpers';
require('body-parser-xml')(bodyParser);
@ -156,6 +171,8 @@ class App {
defaultWorkflowName: string;
defaultCredentialsName: string;
saveDataErrorExecution: string;
saveDataSuccessExecution: string;
@ -196,6 +213,7 @@ class App {
this.endpointWebhookTest = config.get('endpoints.webhookTest') as string;
this.defaultWorkflowName = config.get('workflows.defaultName') as string;
this.defaultCredentialsName = config.get('credentials.defaultName') as string;
this.saveDataErrorExecution = config.get('executions.saveDataOnError') as string;
this.saveDataSuccessExecution = config.get('executions.saveDataOnSuccess') as string;
@ -527,6 +545,7 @@ class App {
// support application/x-www-form-urlencoded post data
this.app.use(
bodyParser.urlencoded({
limit: `${this.payloadSizeMax}mb`,
extended: false,
verify: (req, res, buf) => {
// @ts-ignore
@ -719,41 +738,11 @@ class App {
this.app.get(
`/${this.restEndpoint}/workflows/new`,
ResponseHelper.send(
async (req: WorkflowNameRequest, res: express.Response): Promise<{ name: string }> => {
const nameToReturn =
async (req: NameRequest, res: express.Response): Promise<{ name: string }> => {
const requestedName =
req.query.name && req.query.name !== '' ? req.query.name : this.defaultWorkflowName;
const workflows = await Db.collections.Workflow!.find({
select: ['name'],
where: { name: Like(`${nameToReturn}%`) },
});
// name is unique
if (workflows.length === 0) {
return { name: nameToReturn };
}
const maxSuffix = workflows.reduce((acc: number, { name }) => {
const parts = name.split(`${nameToReturn} `);
if (parts.length > 2) return acc;
const suffix = Number(parts[1]);
// eslint-disable-next-line no-restricted-globals
if (!isNaN(suffix) && Math.ceil(suffix) > acc) {
acc = Math.ceil(suffix);
}
return acc;
}, 0);
// name is duplicate but no numeric suffixes exist yet
if (maxSuffix === 0) {
return { name: `${nameToReturn} 2` };
}
return { name: `${nameToReturn} ${maxSuffix + 1}` };
return await GenericHelpers.generateUniqueName(requestedName, 'workflow');
},
),
);
@ -899,7 +888,6 @@ class App {
await this.externalHooks.run('workflow.delete', [id]);
const isActive = await this.activeWorkflowRunner.isActive(id);
if (isActive) {
// Before deleting a workflow deactivate it
await this.activeWorkflowRunner.remove(id);
@ -1077,7 +1065,9 @@ class App {
`/${this.restEndpoint}/node-parameter-options`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<INodePropertyOptions[]> => {
const nodeType = req.query.nodeType as string;
const nodeTypeAndVersion = JSON.parse(
`${req.query.nodeTypeAndVersion}`,
) as INodeTypeNameVersion;
const path = req.query.path as string;
let credentials: INodeCredentials | undefined;
const currentNodeParameters = JSON.parse(
@ -1092,10 +1082,10 @@ class App {
// @ts-ignore
const loadDataInstance = new LoadNodeParameterOptions(
nodeType,
nodeTypeAndVersion,
nodeTypes,
path,
JSON.parse(`${req.query.currentNodeParameters}`),
currentNodeParameters,
credentials,
);
@ -1112,46 +1102,58 @@ class App {
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
const returnData: INodeTypeDescription[] = [];
const onlyLatest = req.query.onlyLatest === 'true';
const nodeTypes = NodeTypes();
const allNodes = nodeTypes.getAll();
allNodes.forEach((nodeData) => {
// Make a copy of the object. If we don't do this, then when
// The method below is called the properties are removed for good
// This happens because nodes are returned as reference.
const nodeInfo: INodeTypeDescription = { ...nodeData.description };
const getNodeDescription = (nodeType: INodeType): INodeTypeDescription => {
const nodeInfo: INodeTypeDescription = { ...nodeType.description };
if (req.query.includeProperties !== 'true') {
// @ts-ignore
delete nodeInfo.properties;
}
returnData.push(nodeInfo);
});
return nodeInfo;
};
if (onlyLatest) {
allNodes.forEach((nodeData) => {
const nodeType = NodeHelpers.getVersionedTypeNode(nodeData);
const nodeInfo: INodeTypeDescription = getNodeDescription(nodeType);
returnData.push(nodeInfo);
});
} else {
allNodes.forEach((nodeData) => {
const allNodeTypes = NodeHelpers.getVersionedTypeNodeAll(nodeData);
allNodeTypes.forEach((element) => {
const nodeInfo: INodeTypeDescription = getNodeDescription(element);
returnData.push(nodeInfo);
});
});
}
return returnData;
},
),
);
// Returns node information baesd on namese
// Returns node information based on node names and versions
this.app.post(
`/${this.restEndpoint}/node-types`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
const nodeNames = _.get(req, 'body.nodeNames', []) as string[];
const nodeInfos = _.get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[];
const nodeTypes = NodeTypes();
return nodeNames
.map((name) => {
try {
return nodeTypes.getByName(name);
} catch (e) {
return undefined;
}
})
.filter((nodeData) => !!nodeData)
.map((nodeData) => nodeData!.description);
const returnData: INodeTypeDescription[] = [];
nodeInfos.forEach((nodeInfo) => {
const nodeType = nodeTypes.getByNameAndVersion(nodeInfo.name, nodeInfo.version);
if (nodeType?.description) {
returnData.push(nodeType.description);
}
});
return returnData;
},
),
);
@ -1173,7 +1175,7 @@ class App {
}`;
const nodeTypes = NodeTypes();
const nodeType = nodeTypes.getByName(nodeTypeName);
const nodeType = nodeTypes.getByNameAndVersion(nodeTypeName);
if (nodeType === undefined) {
res.status(404).send('The nodeType is not known.');
@ -1236,6 +1238,18 @@ class App {
// Credentials
// ----------------------------------------
this.app.get(
`/${this.restEndpoint}/credentials/new`,
ResponseHelper.send(
async (req: NameRequest, res: express.Response): Promise<{ name: string }> => {
const requestedName =
req.query.name && req.query.name !== '' ? req.query.name : this.defaultCredentialsName;
return await GenericHelpers.generateUniqueName(requestedName, 'credentials');
},
),
);
// Deletes a specific credential
this.app.delete(
`/${this.restEndpoint}/credentials/:id`,
@ -1322,6 +1336,95 @@ class App {
),
);
// Test credentials
this.app.post(
`/${this.restEndpoint}/credentials-test`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<NodeCredentialTestResult> => {
const incomingData = req.body as NodeCredentialTestRequest;
const credentialType = incomingData.credentials.type;
// Find nodes that can test this credential.
const nodeTypes = NodeTypes();
const allNodes = nodeTypes.getAll();
let foundTestFunction:
| ((
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
) => Promise<NodeCredentialTestResult>)
| undefined;
const nodeThatCanTestThisCredential = allNodes.find((node) => {
if (
incomingData.nodeToTestWith &&
node.description.name !== incomingData.nodeToTestWith
) {
return false;
}
if (node instanceof NodeVersionedType) {
const versionNames = Object.keys((node as INodeVersionedType).nodeVersions);
for (const versionName of versionNames) {
const nodeType = (node as INodeVersionedType).nodeVersions[
versionName as unknown as number
];
// eslint-disable-next-line @typescript-eslint/no-loop-func
const credentialTestable = nodeType.description.credentials?.find((credential) => {
const testFunctionSearch =
credential.name === credentialType && !!credential.testedBy;
if (testFunctionSearch) {
foundTestFunction = (node as unknown as INodeType).methods!.credentialTest![
credential.testedBy!
];
}
return testFunctionSearch;
});
if (credentialTestable) {
return true;
}
}
return false;
}
const credentialTestable = (node as INodeType).description.credentials?.find(
(credential) => {
const testFunctionSearch =
credential.name === credentialType && !!credential.testedBy;
if (testFunctionSearch) {
foundTestFunction = (node as INodeType).methods!.credentialTest![
credential.testedBy!
];
}
return testFunctionSearch;
},
);
return !!credentialTestable;
});
if (!nodeThatCanTestThisCredential) {
return Promise.resolve({
status: 'Error',
message: 'There are no nodes that can test this credential.',
});
}
if (foundTestFunction === undefined) {
return Promise.resolve({
status: 'Error',
message: 'No testing function found for this credential.',
});
}
const credentialTestFunctions = NodeExecuteFunctions.getCredentialTestFunctions();
const output = await foundTestFunction.call(
credentialTestFunctions,
incomingData.credentials,
);
return Promise.resolve(output);
},
),
);
// Updates existing credentials
this.app.patch(
`/${this.restEndpoint}/credentials/:id`,
@ -1539,6 +1642,42 @@ class App {
),
);
this.app.get(
`/${this.restEndpoint}/credential-icon/:credentialType`,
async (req: express.Request, res: express.Response): Promise<void> => {
try {
const credentialName = req.params.credentialType;
const credentialType = CredentialTypes().getByName(credentialName);
if (credentialType === undefined) {
res.status(404).send('The credentialType is not known.');
return;
}
if (credentialType.icon === undefined) {
res.status(404).send('No icon found for credential.');
return;
}
if (!credentialType.icon.startsWith('file:')) {
res.status(404).send('Credential does not have a file icon.');
return;
}
const filepath = credentialType.icon.substr(5);
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
res.setHeader('Cache-control', `private max-age=${maxAge}`);
res.sendFile(filepath);
} catch (error) {
// Error response
return ResponseHelper.sendErrorResponse(res, error);
}
},
);
// ----------------------------------------
// OAuth1-Credential/Auth
// ----------------------------------------

View file

@ -139,7 +139,10 @@ export async function executeWebhook(
responseCallback: (error: Error | null, data: IResponseCallbackData) => void,
): Promise<string | undefined> {
// Get the nodeType to know which responseMode is set
const nodeType = workflow.nodeTypes.getByName(workflowStartNode.type);
const nodeType = workflow.nodeTypes.getByNameAndVersion(
workflowStartNode.type,
workflowStartNode.typeVersion,
);
if (nodeType === undefined) {
const errorMessage = `The type of the webhook node "${workflowStartNode.name}" is not known.`;
responseCallback(new Error(errorMessage), {});

View file

@ -1,3 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable no-underscore-dangle */
/* eslint-disable no-continue */
@ -226,13 +229,13 @@ export function getNodeTypeData(nodes: INode[]): ITransferNodeTypes {
// can be loaded again in the process
const returnData: ITransferNodeTypes = {};
for (const nodeTypeName of neededNodeTypes) {
if (nodeTypes.nodeTypes[nodeTypeName] === undefined) {
throw new Error(`The NodeType "${nodeTypeName}" could not be found!`);
if (nodeTypes.nodeTypes[nodeTypeName.type] === undefined) {
throw new Error(`The NodeType "${nodeTypeName.type}" could not be found!`);
}
returnData[nodeTypeName] = {
className: nodeTypes.nodeTypes[nodeTypeName].type.constructor.name,
sourcePath: nodeTypes.nodeTypes[nodeTypeName].sourcePath,
returnData[nodeTypeName.type] = {
className: nodeTypes.nodeTypes[nodeTypeName.type].type.constructor.name,
sourcePath: nodeTypes.nodeTypes[nodeTypeName.type].sourcePath,
};
}
@ -306,12 +309,12 @@ export function getCredentialsDataByNodes(nodes: INode[]): ICredentialsTypeData
* @param {INode[]} nodes
* @returns {string[]}
*/
export function getNeededNodeTypes(nodes: INode[]): string[] {
export function getNeededNodeTypes(nodes: INode[]): Array<{ type: string; version: number }> {
// Check which node-types have to be loaded
const neededNodeTypes: string[] = [];
const neededNodeTypes: Array<{ type: string; version: number }> = [];
for (const node of nodes) {
if (!neededNodeTypes.includes(node.type)) {
neededNodeTypes.push(node.type);
if (neededNodeTypes.find((neededNodes) => node.type === neededNodes.type) === undefined) {
neededNodeTypes.push({ type: node.type, version: node.typeVersion });
}
}
@ -408,9 +411,8 @@ export function throwDuplicateEntryError(error: Error) {
throw new ResponseHelper.ResponseError(errorMessage, undefined, 400);
}
export type WorkflowNameRequest = Express.Request & {
export type NameRequest = Express.Request & {
query: {
name?: string;
offset?: string;
};
};

View file

@ -357,7 +357,7 @@ export class WorkflowRunner {
try {
job = await this.jobQueue.add(jobData, jobOptions);
console.log(`Started with ID: ${job.id.toString()}`);
console.log(`Started with job ID: ${job.id.toString()} (Execution ID: ${executionId})`);
hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerMain(
data.executionMode,

View file

@ -98,7 +98,15 @@ export class WorkflowRunnerProcess {
const tempModule = require(filePath);
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const nodeObject = new tempModule[className]();
if (nodeObject.getNodeType !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
tempNode = nodeObject.getNodeType();
} else {
tempNode = nodeObject;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
tempNode = new tempModule[className]() as INodeType;
} catch (error) {
throw new Error(`Error loading node "${nodeTypeName}" from: "${filePath}"`);

View file

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "0.81.0",
"version": "0.84.0",
"description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -42,15 +42,18 @@
"typescript": "~4.3.5"
},
"dependencies": {
"axios": "^0.21.1",
"client-oauth2": "^4.2.5",
"cron": "^1.7.2",
"crypto-js": "~4.1.1",
"file-type": "^14.6.2",
"form-data": "^4.0.0",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.66.0",
"n8n-workflow": "~0.70.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"qs": "^6.10.1",
"request": "^2.88.2",
"request-promise-native": "^1.0.7"
},

View file

@ -2,11 +2,13 @@
import {
IAllExecuteFunctions,
IBinaryData,
ICredentialTestFunctions as ICredentialTestFunctionsBase,
ICredentialType,
IDataObject,
IExecuteFunctions as IExecuteFunctionsBase,
IExecuteSingleFunctions as IExecuteSingleFunctionsBase,
IHookFunctions as IHookFunctionsBase,
IHttpRequestOptions,
ILoadOptionsFunctions as ILoadOptionsFunctionsBase,
INodeExecutionData,
INodeType,
@ -33,13 +35,14 @@ export interface IProcessMessage {
export interface IExecuteFunctions extends IExecuteFunctionsBase {
helpers: {
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
prepareBinaryData(
binaryData: Buffer,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData>;
getBinaryDataBuffer(itemIndex: number, propertyName: string): Promise<Buffer>;
request: requestPromise.RequestPromiseAPI;
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -57,12 +60,13 @@ export interface IExecuteFunctions extends IExecuteFunctionsBase {
export interface IExecuteSingleFunctions extends IExecuteSingleFunctionsBase {
helpers: {
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
prepareBinaryData(
binaryData: Buffer,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData>;
request: requestPromise.RequestPromiseAPI;
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -79,12 +83,13 @@ export interface IExecuteSingleFunctions extends IExecuteSingleFunctionsBase {
export interface IPollFunctions extends IPollFunctionsBase {
helpers: {
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
prepareBinaryData(
binaryData: Buffer,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData>;
request: requestPromise.RequestPromiseAPI;
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -106,12 +111,13 @@ export interface IResponseError extends Error {
export interface ITriggerFunctions extends ITriggerFunctionsBase {
helpers: {
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
prepareBinaryData(
binaryData: Buffer,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData>;
request: requestPromise.RequestPromiseAPI;
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -143,7 +149,8 @@ export interface IUserSettings {
export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase {
helpers: {
request?: requestPromise.RequestPromiseAPI;
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
request?: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
requestOAuth2?: (
this: IAllExecuteFunctions,
credentialsType: string,
@ -158,9 +165,16 @@ export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase {
};
}
export interface IHookFunctions extends IHookFunctionsBase {
export interface ICredentialTestFunctions extends ICredentialTestFunctionsBase {
helpers: {
request: requestPromise.RequestPromiseAPI;
};
}
export interface IHookFunctions extends IHookFunctionsBase {
helpers: {
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -177,12 +191,13 @@ export interface IHookFunctions extends IHookFunctionsBase {
export interface IWebhookFunctions extends IWebhookFunctionsBase {
helpers: {
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
prepareBinaryData(
binaryData: Buffer,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData>;
request: requestPromise.RequestPromiseAPI;
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,

View file

@ -1,9 +1,15 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {
INode,
INodeCredentials,
INodeParameters,
INodePropertyOptions,
INodeTypeNameVersion,
INodeTypes,
IWorkflowExecuteAdditionalData,
Workflow,
@ -21,27 +27,30 @@ export class LoadNodeParameterOptions {
workflow: Workflow;
constructor(
nodeTypeName: string,
nodeTypeNameAndVersion: INodeTypeNameVersion,
nodeTypes: INodeTypes,
path: string,
currentNodeParameters: INodeParameters,
credentials?: INodeCredentials,
) {
const nodeType = nodeTypes.getByNameAndVersion(
nodeTypeNameAndVersion.name,
nodeTypeNameAndVersion.version,
);
this.path = path;
const nodeType = nodeTypes.getByName(nodeTypeName);
if (nodeType === undefined) {
throw new Error(`The node-type "${nodeTypeName}" is not known!`);
throw new Error(
`The node-type "${nodeTypeNameAndVersion.name} v${nodeTypeNameAndVersion.version}" is not known!`,
);
}
const nodeData: INode = {
parameters: currentNodeParameters,
name: TEMP_NODE_NAME,
type: nodeTypeName,
typeVersion: 1,
type: nodeTypeNameAndVersion.name,
typeVersion: nodeTypeNameAndVersion.version,
position: [0, 0],
};
if (credentials) {
nodeData.credentials = credentials;
}
@ -91,12 +100,13 @@ export class LoadNodeParameterOptions {
): Promise<INodePropertyOptions[]> {
const node = this.workflow.getNode(TEMP_NODE_NAME);
const nodeType = this.workflow.nodeTypes.getByName(node!.type);
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(node!.type, node?.typeVersion);
if (
nodeType!.methods === undefined ||
nodeType!.methods.loadOptions === undefined ||
nodeType!.methods.loadOptions[methodName] === undefined
!nodeType ||
nodeType.methods === undefined ||
nodeType.methods.loadOptions === undefined ||
nodeType.methods.loadOptions[methodName] === undefined
) {
throw new Error(
`The node-type "${node!.type}" does not have the method "${methodName}" defined!`,
@ -110,6 +120,6 @@ export class LoadNodeParameterOptions {
additionalData,
);
return nodeType!.methods.loadOptions[methodName].call(thisArgs);
return nodeType.methods.loadOptions[methodName].call(thisArgs);
}
}

View file

@ -1,3 +1,4 @@
/* eslint-disable no-lonely-if */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-prototype-builtins */
/* eslint-disable @typescript-eslint/no-unused-vars */
@ -13,6 +14,7 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable no-param-reassign */
import {
GenericValue,
IAllExecuteFunctions,
IBinaryData,
IContextObject,
@ -22,6 +24,9 @@ import {
IExecuteFunctions,
IExecuteSingleFunctions,
IExecuteWorkflowInfo,
IHttpRequestOptions,
IN8nHttpFullResponse,
IN8nHttpResponse,
INode,
INodeExecutionData,
INodeParameters,
@ -48,6 +53,8 @@ import {
LoggerProxy as Logger,
} from 'n8n-workflow';
import { Agent } from 'https';
import { stringify } from 'qs';
import * as clientOAuth1 from 'oauth-1.0a';
import { Token } from 'oauth-1.0a';
import * as clientOAuth2 from 'client-oauth2';
@ -55,6 +62,7 @@ import * as clientOAuth2 from 'client-oauth2';
import { get } from 'lodash';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as express from 'express';
import * as FormData from 'form-data';
import * as path from 'path';
import { OptionsWithUri, OptionsWithUrl } from 'request';
import * as requestPromise from 'request-promise-native';
@ -62,9 +70,12 @@ import { createHmac } from 'crypto';
import { fromBuffer } from 'file-type';
import { lookup } from 'mime-types';
import axios, { AxiosProxyConfig, AxiosRequestConfig, Method } from 'axios';
import { URLSearchParams } from 'url';
// eslint-disable-next-line import/no-cycle
import {
BINARY_ENCODING,
ICredentialTestFunctions,
IHookFunctions,
ILoadOptionsFunctions,
IResponseError,
@ -72,10 +83,425 @@ import {
PLACEHOLDER_EMPTY_EXECUTION_ID,
} from '.';
axios.defaults.timeout = 300000;
// Prevent axios from adding x-form-www-urlencoded headers by default
axios.defaults.headers.post = {};
const requestPromiseWithDefaults = requestPromise.defaults({
timeout: 300000, // 5 minutes
});
async function parseRequestObject(requestObject: IDataObject) {
// This function is a temporary implementation
// That translates all http requests done via
// the request library to axios directly
// We are not using n8n's interface as it would
// an unnecessary step, considering the `request`
// helper can be deprecated and removed.
const axiosConfig: AxiosRequestConfig = {};
if (requestObject.headers !== undefined) {
axiosConfig.headers = requestObject.headers as string;
}
// Let's start parsing the hardest part, which is the request body.
// The process here is as following?
// - Check if we have a `content-type` header. If this was set,
// we will follow
// - Check if the `form` property was set. If yes, then it's x-www-form-urlencoded
// - Check if the `formData` property exists. If yes, then it's multipart/form-data
// - Lastly, we should have a regular `body` that is probably a JSON.
const contentTypeHeaderKeyName =
axiosConfig.headers &&
Object.keys(axiosConfig.headers).find(
(headerName) => headerName.toLowerCase() === 'content-type',
);
const contentType =
contentTypeHeaderKeyName &&
(axiosConfig.headers[contentTypeHeaderKeyName] as string | undefined);
if (contentType === 'application/x-www-form-urlencoded' && requestObject.formData === undefined) {
// there are nodes incorrectly created, informing the content type header
// and also using formData. Request lib takes precedence for the formData.
// We will do the same.
// Merge body and form properties.
// @ts-ignore
axiosConfig.data =
typeof requestObject.body === 'string'
? requestObject.body
: new URLSearchParams(
Object.assign(requestObject.body || {}, requestObject.form || {}) as Record<
string,
string
>,
);
} else if (contentType && contentType.includes('multipart/form-data') !== false) {
if (requestObject.formData !== undefined && requestObject.formData instanceof FormData) {
axiosConfig.data = requestObject.formData;
} else {
const allData = Object.assign(requestObject.body || {}, requestObject.formData || {});
const objectKeys = Object.keys(allData);
if (objectKeys.length > 0) {
// Should be a standard object. We must convert to formdata
const form = new FormData();
objectKeys.forEach((key) => {
const formField = (allData as IDataObject)[key] as IDataObject;
if (formField.hasOwnProperty('value') && formField.value instanceof Buffer) {
let filename;
// @ts-ignore
if (!!formField.options && formField.options.filename !== undefined) {
filename = (formField.options as IDataObject).filename as string;
}
form.append(key, formField.value, filename);
} else {
form.append(key, formField);
}
});
axiosConfig.data = form;
}
}
// replace the existing header with a new one that
// contains the boundary property.
// @ts-ignore
delete axiosConfig.headers[contentTypeHeaderKeyName];
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
} else {
// When using the `form` property it means the content should be x-www-form-urlencoded.
if (requestObject.form !== undefined && requestObject.body === undefined) {
// If we have only form
axiosConfig.data = new URLSearchParams(requestObject.form as Record<string, string>);
if (axiosConfig.headers !== undefined) {
// remove possibly existing content-type headers
const headers = Object.keys(axiosConfig.headers);
headers.forEach((header) =>
header.toLowerCase() === 'content-type' ? delete axiosConfig.headers[header] : null,
);
axiosConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded';
} else {
axiosConfig.headers = {
'Content-Type': 'application/x-www-form-urlencoded',
};
}
} else if (requestObject.formData !== undefined) {
// remove any "content-type" that might exist.
if (axiosConfig.headers !== undefined) {
const headers = Object.keys(axiosConfig.headers);
headers.forEach((header) =>
header.toLowerCase() === 'content-type' ? delete axiosConfig.headers[header] : null,
);
}
if (requestObject.formData instanceof FormData) {
axiosConfig.data = requestObject.formData;
} else {
const objectKeys = Object.keys(requestObject.formData as object);
if (objectKeys.length > 0) {
// Should be a standard object. We must convert to formdata
const form = new FormData();
objectKeys.forEach((key) => {
const formField = (requestObject.formData as IDataObject)[key] as IDataObject;
if (formField.hasOwnProperty('value') && formField.value instanceof Buffer) {
let filename;
// @ts-ignore
if (!!formField.options && formField.options.filename !== undefined) {
filename = (formField.options as IDataObject).filename as string;
}
form.append(key, formField.value, filename);
} else {
form.append(key, formField);
}
});
axiosConfig.data = form;
}
}
// Mix in headers as FormData creates the boundary.
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
} else if (requestObject.body !== undefined) {
// If we have body and possibly form
if (requestObject.form !== undefined) {
// merge both objects when exist.
requestObject.body = Object.assign(requestObject.body, requestObject.form);
}
axiosConfig.data = requestObject.body as FormData | GenericValue | GenericValue[];
}
}
if (requestObject.uri !== undefined) {
axiosConfig.url = requestObject.uri as string;
}
if (requestObject.url !== undefined) {
axiosConfig.url = requestObject.url as string;
}
if (requestObject.method !== undefined) {
axiosConfig.method = requestObject.method as Method;
}
if (requestObject.qs !== undefined && Object.keys(requestObject.qs as object).length > 0) {
axiosConfig.params = requestObject.qs as IDataObject;
}
if (requestObject.useQuerystring === true) {
axiosConfig.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: 'repeat' });
};
}
if (requestObject.auth !== undefined) {
// Check support for sendImmediately
if ((requestObject.auth as IDataObject).bearer !== undefined) {
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
Authorization: `Bearer ${(requestObject.auth as IDataObject).bearer}`,
});
} else {
const authObj = requestObject.auth as IDataObject;
// Request accepts both user/username and pass/password
axiosConfig.auth = {
username: (authObj.user || authObj.username) as string,
password: (authObj.password || authObj.pass) as string,
};
}
}
// Only set header if we have a body, otherwise it may fail
if (requestObject.json === true) {
// Add application/json headers - do not set charset as it breaks a lot of stuff
// only add if no other accept headers was sent.
const acceptHeaderExists =
axiosConfig.headers === undefined
? false
: Object.keys(axiosConfig.headers)
.map((headerKey) => headerKey.toLowerCase())
.includes('accept');
if (!acceptHeaderExists) {
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
Accept: 'application/json',
});
}
}
if (requestObject.json === false) {
// Prevent json parsing
axiosConfig.transformResponse = (res) => res;
}
// Axios will follow redirects by default, so we simply tell it otherwise if needed.
if (
requestObject.followRedirect === false &&
((requestObject.method as string | undefined) || 'get').toLowerCase() === 'get'
) {
axiosConfig.maxRedirects = 0;
}
if (
requestObject.followAllRedirect === false &&
((requestObject.method as string | undefined) || 'get').toLowerCase() !== 'get'
) {
axiosConfig.maxRedirects = 0;
}
if (requestObject.rejectUnauthorized === false) {
axiosConfig.httpsAgent = new Agent({
rejectUnauthorized: false,
});
}
if (requestObject.timeout !== undefined) {
axiosConfig.timeout = requestObject.timeout as number;
}
if (requestObject.proxy !== undefined) {
axiosConfig.proxy = requestObject.proxy as AxiosProxyConfig;
}
if (requestObject.encoding === null) {
// When downloading files, return an arrayBuffer.
axiosConfig.responseType = 'arraybuffer';
}
// If we don't set an accept header
// Axios forces "application/json, text/plan, */*"
// Which causes some nodes like NextCloud to break
// as the service returns XML unless requested otherwise.
const allHeaders = axiosConfig.headers ? Object.keys(axiosConfig.headers) : [];
if (!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'accept')) {
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { accept: '*/*' });
}
if (
axiosConfig.data !== undefined &&
!(axiosConfig.data instanceof Buffer) &&
!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type')
) {
// Use default header for application/json
// If we don't specify this here, axios will add
// application/json; charset=utf-8
// and this breaks a lot of stuff
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
'content-type': 'application/json',
});
}
/**
* Missing properties:
* encoding (need testing)
* gzip (ignored - default already works)
* resolveWithFullResponse (implemented elsewhere)
* simple (???)
*/
return axiosConfig;
}
async function proxyRequestToAxios(
uriOrObject: string | IDataObject,
options?: IDataObject,
): Promise<any> {
// tslint:disable-line:no-any
// Check if there's a better way of getting this config here
if (process.env.N8N_USE_DEPRECATED_REQUEST_LIB) {
// @ts-ignore
return requestPromiseWithDefaults.call(null, uriOrObject, options);
}
let axiosConfig: AxiosRequestConfig = {};
let configObject: IDataObject;
if (uriOrObject !== undefined && typeof uriOrObject === 'string') {
axiosConfig.url = uriOrObject;
}
if (uriOrObject !== undefined && typeof uriOrObject === 'object') {
configObject = uriOrObject;
} else {
configObject = options || {};
}
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject));
return new Promise((resolve, reject) => {
axios(axiosConfig)
.then((response) => {
if (configObject.resolveWithFullResponse === true) {
resolve({
body: response.data,
headers: response.headers,
statusCode: response.status,
statusMessage: response.statusText,
request: response.request,
});
} else {
resolve(response.data);
}
})
.catch((error) => {
reject(error);
});
});
}
function searchForHeader(headers: IDataObject, headerName: string) {
if (headers === undefined) {
return undefined;
}
const headerNames = Object.keys(headers);
headerName = headerName.toLowerCase();
return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName);
}
function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequestConfig {
// Destructure properties with the same name first.
const { headers, method, timeout, auth, proxy, url } = n8nRequest;
const axiosRequest = {
headers: headers ?? {},
method,
timeout,
auth,
proxy,
url,
} as AxiosRequestConfig;
axiosRequest.params = n8nRequest.qs;
if (n8nRequest.disableFollowRedirect === true) {
axiosRequest.maxRedirects = 0;
}
if (n8nRequest.encoding !== undefined) {
axiosRequest.responseType = n8nRequest.encoding;
}
if (n8nRequest.skipSslCertificateValidation === true) {
axiosRequest.httpsAgent = new Agent({
rejectUnauthorized: false,
});
}
if (n8nRequest.arrayFormat !== undefined) {
axiosRequest.paramsSerializer = (params) => {
return stringify(params, { arrayFormat: n8nRequest.arrayFormat });
};
}
if (n8nRequest.body) {
axiosRequest.data = n8nRequest.body;
// Let's add some useful header standards here.
const existingContentTypeHeaderKey = searchForHeader(axiosRequest.headers, 'content-type');
if (existingContentTypeHeaderKey === undefined) {
// We are only setting content type headers if the user did
// not set it already manually. We're not overriding, even if it's wrong.
if (axiosRequest.data instanceof FormData) {
axiosRequest.headers = axiosRequest.headers || {};
axiosRequest.headers['Content-Type'] = 'multipart/form-data';
} else if (axiosRequest.data instanceof URLSearchParams) {
axiosRequest.headers = axiosRequest.headers || {};
axiosRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
}
}
if (n8nRequest.json) {
const key = searchForHeader(axiosRequest.headers, 'accept');
// If key exists, then the user has set both accept
// header and the json flag. Header should take precedence.
if (!key) {
axiosRequest.headers.Accept = 'application/json';
}
}
const userAgentHeader = searchForHeader(axiosRequest.headers, 'user-agent');
// If key exists, then the user has set both accept
// header and the json flag. Header should take precedence.
if (!userAgentHeader) {
axiosRequest.headers['User-Agent'] = 'n8n';
}
return axiosRequest;
}
async function httpRequest(
requestParams: IHttpRequestOptions,
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
// tslint:disable-line:no-any
const axiosRequest = convertN8nRequestToAxios(requestParams);
const result = await axios(axiosRequest);
if (requestParams.returnFullResponse) {
return {
body: result.data,
headers: result.headers,
statusCode: result.status,
statusMessage: result.statusText,
};
}
return result.data;
}
/**
* Returns binary data buffer for given item index and property name.
*
@ -411,7 +837,7 @@ export async function getCredentials(
itemIndex?: number,
): Promise<ICredentialDataDecryptedObject | undefined> {
// Get the NodeType as it has the information if the credentials are required
const nodeType = workflow.nodeTypes.getByName(node.type);
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (nodeType === undefined) {
throw new NodeOperationError(
node,
@ -542,7 +968,7 @@ export function getNodeParameter(
additionalKeys: IWorkflowDataProxyAdditionalKeys,
fallbackValue?: any,
): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object {
const nodeType = workflow.nodeTypes.getByName(node.type);
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (nodeType === undefined) {
throw new Error(`Node type "${node.type}" is not known so can not return paramter value!`);
}
@ -668,7 +1094,7 @@ export function getWebhookDescription(
workflow: Workflow,
node: INode,
): IWebhookDescription | undefined {
const nodeType = workflow.nodeTypes.getByName(node.type) as INodeType;
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType;
if (nodeType.description.webhooks === undefined) {
// Node does not have any webhooks so return
@ -775,8 +1201,9 @@ export function getExecutePollFunctions(
return workflow.getStaticData(type, node);
},
helpers: {
httpRequest,
prepareBinaryData,
request: requestPromiseWithDefaults,
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -880,8 +1307,10 @@ export function getExecuteTriggerFunctions(
return workflow.getStaticData(type, node);
},
helpers: {
httpRequest,
prepareBinaryData,
request: requestPromiseWithDefaults,
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -1071,6 +1500,7 @@ export function getExecuteFunctions(
}
},
helpers: {
httpRequest,
prepareBinaryData,
async getBinaryDataBuffer(
itemIndex: number,
@ -1079,7 +1509,7 @@ export function getExecuteFunctions(
): Promise<Buffer> {
return getBinaryDataBuffer.call(this, inputData, itemIndex, propertyName, inputIndex);
},
request: requestPromiseWithDefaults,
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -1251,8 +1681,9 @@ export function getExecuteSingleFunctions(
return workflow.getStaticData(type, node);
},
helpers: {
httpRequest,
prepareBinaryData,
request: requestPromiseWithDefaults,
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -1280,6 +1711,14 @@ export function getExecuteSingleFunctions(
})(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex);
}
export function getCredentialTestFunctions(): ICredentialTestFunctions {
return {
helpers: {
request: requestPromiseWithDefaults,
},
};
}
/**
* Returns the execute functions regular nodes have access to in load-options-function.
*
@ -1357,7 +1796,8 @@ export function getLoadOptionsFunctions(
return additionalData.restApiUrl;
},
helpers: {
request: requestPromiseWithDefaults,
httpRequest,
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -1476,7 +1916,8 @@ export function getExecuteHookFunctions(
return workflow.getStaticData(type, node);
},
helpers: {
request: requestPromiseWithDefaults,
httpRequest,
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,
@ -1621,8 +2062,9 @@ export function getExecuteWebhookFunctions(
},
prepareOutputData: NodeHelpers.prepareOutputData,
helpers: {
httpRequest,
prepareBinaryData,
request: requestPromiseWithDefaults,
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,
credentialsType: string,

View file

@ -27,6 +27,8 @@ import {
IWaitingForExecution,
IWorkflowExecuteAdditionalData,
LoggerProxy as Logger,
NodeApiError,
NodeOperationError,
Workflow,
WorkflowExecuteMode,
WorkflowOperationError,
@ -624,9 +626,9 @@ export class WorkflowExecute {
} catch (error) {
// Set the error that it can be saved correctly
executionError = {
...error,
message: error.message,
stack: error.stack,
...(error as NodeOperationError | NodeApiError),
message: (error as NodeOperationError | NodeApiError).message,
stack: (error as NodeOperationError | NodeApiError).stack,
};
// Set the incoming data of the node that it can be saved correctly
@ -837,9 +839,9 @@ export class WorkflowExecute {
this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name;
executionError = {
...error,
message: error.message,
stack: error.stack,
...(error as NodeOperationError | NodeApiError),
message: (error as NodeOperationError | NodeApiError).message,
stack: (error as NodeOperationError | NodeApiError).stack,
};
Logger.debug(`Running node "${executionNode.name}" finished with error`, {
@ -889,6 +891,22 @@ export class WorkflowExecute {
}
}
// Merge error information to default output for now
// As the new nodes can report the errors in
// the `error` property.
for (const execution of nodeSuccessData!) {
for (const lineResult of execution) {
if (lineResult.json.$error !== undefined && lineResult.json.$json !== undefined) {
lineResult.error = lineResult.json.$error as NodeApiError | NodeOperationError;
lineResult.json = {
error: (lineResult.json.$error as NodeApiError | NodeOperationError).message,
};
} else if (lineResult.error !== undefined) {
lineResult.json = { error: lineResult.error.message };
}
}
}
// Node executed successfully. So add data and go on.
taskData.data = {
main: nodeSuccessData,

View file

@ -14,6 +14,7 @@ import {
ITaskData,
IWorkflowBase,
IWorkflowExecuteAdditionalData,
NodeHelpers,
NodeParameterValue,
WorkflowHooks,
} from 'n8n-workflow';
@ -720,11 +721,15 @@ class NodeTypesClass implements INodeTypes {
async init(nodeTypes: INodeTypeData): Promise<void> {}
getAll(): INodeType[] {
return Object.values(this.nodeTypes).map((data) => data.type);
return Object.values(this.nodeTypes).map((data) => NodeHelpers.getVersionedTypeNode(data.type));
}
getByName(nodeType: string): INodeType {
return this.nodeTypes[nodeType].type;
return this.getByNameAndVersion(nodeType);
}
getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version);
}
}

View file

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

View file

@ -6,7 +6,7 @@
:size="props.size"
:loading="props.loading"
:title="props.title || props.label"
:class="$style[$options.getClass(props)]"
:class="$options.getClass(props, $style)"
:round="!props.circle && props.round"
:circle="props.circle"
:style="$options.styles(props)"
@ -91,6 +91,10 @@ export default {
type: Boolean,
default: false,
},
transparentBackground: {
type: Boolean,
default: false,
},
},
components: {
ElButton,
@ -106,10 +110,16 @@ export default {
...(props.fullWidth ? { width: '100%' } : {}),
};
},
getClass(props: { type: string; theme?: string }): string {
return props.type === 'text'
getClass(props: { type: string; theme?: string, transparentBackground: boolean }, $style: any): string {
const theme = props.type === 'text'
? 'text'
: `${props.type}-${props.theme || 'primary'}`;
if (props.transparentBackground) {
return `${$style[theme]} ${$style['transparent']}`;
}
return $style[theme];
},
};
</script>
@ -289,6 +299,11 @@ $color-danger-shade: lightness(
--button-active-border-color: transparent;
}
.transparent {
--button-background-color: transparent;
--button-active-background-color: transparent;
}
.icon {
display: inline-flex;

View file

@ -0,0 +1,17 @@
import N8nInfoTip from './InfoTip.vue';
export default {
title: 'Atoms/InfoTip',
component: N8nInfoTip,
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nInfoTip,
},
template:
'<n8n-info-tip>Need help doing something? <a href="/docs" target="_blank">Open docs</a></n8n-info-tip>',
});
export const InputLabel = Template.bind({});

View file

@ -0,0 +1,36 @@
<template functional>
<div :class="$style.infotip">
<n8n-icon icon="info-circle" /> <span><slot></slot></span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import N8nIcon from '../N8nIcon';
Vue.component('N8nIcon', N8nIcon);
export default {
name: 'n8n-info-tip',
props: {
},
};
</script>
<style lang="scss" module>
.infotip {
color: var(--color-text-light);
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold);
line-height: var(--font-size-s);
word-break: normal;
display: flex;
align-items: center;
svg {
font-size: var(--font-size-s);
margin-right: var(--spacing-4xs);
}
}
</style>

View file

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

View file

@ -1,16 +1,18 @@
<template functional>
<div :class="$style.inputLabel">
<label>
<div :class="$style.label">
<span>{{ props.label }}</span>
<span v-if="props.tooltipText" :class="$style.infoIcon">
<n8n-tooltip :content="props.tooltipText" placement="top">
<n8n-icon icon="info-circle" />
</n8n-tooltip>
</span>
</div>
<slot></slot>
</label>
<div :class="$style.label">
<span>
{{ $options.methods.addTargetBlank(props.label) }}
<span v-if="props.required" :class="$style.required">*</span>
</span>
<span :class="$style.infoIcon" v-if="props.tooltipText">
<n8n-tooltip placement="top" :popper-class="$style.tooltipPopper">
<n8n-icon icon="question-circle" />
<div slot="content" v-html="props.tooltipText"></div>
</n8n-tooltip>
</span>
</div>
<slot></slot>
</div>
</template>
@ -20,6 +22,8 @@ import Vue from 'vue';
import N8nTooltip from '../N8nTooltip';
import N8nIcon from '../N8nIcon';
import { addTargetBlank } from '../utils/helpers';
Vue.component('N8nIcon', N8nIcon);
Vue.component('N8nTooltip', N8nTooltip);
@ -33,6 +37,12 @@ export default {
tooltipText: {
type: String,
},
required: {
type: Boolean,
},
},
methods: {
addTargetBlank,
},
};
</script>
@ -48,10 +58,22 @@ export default {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
margin-bottom: var(--spacing-2xs);
* {
margin-right: var(--spacing-4xs);
}
}
.infoIcon {
color: var(--color-text-light);
display: var(--info-icon-display, none);
}
.required {
color: var(--color-primary);
}
.tooltipPopper {
max-width: 400px;
}
</style>

View file

@ -0,0 +1,46 @@
import N8nMenu from './Menu.vue';
import N8nMenuItem from '../N8nMenuItem';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/Menu',
component: N8nMenu,
argTypes: {
type: {
control: 'select',
options: ['primary', 'secondary'],
},
},
parameters: {
backgrounds: { default: '--color-background-xlight' },
},
};
const methods = {
onSelect: action('select'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nMenu,
N8nMenuItem,
},
template:
`<n8n-menu v-bind="$props" @select="onSelect">
<n8n-menu-item index="1"> <span slot="title">Item 1</span> </n8n-menu-item>
<n8n-menu-item index="2"> <span slot="title">Item 2</span> </n8n-menu-item>
</n8n-menu>`,
methods,
});
export const Primary = Template.bind({});
Primary.parameters = {
backgrounds: { default: '--color-background-light' },
};
export const Secondary = Template.bind({});
Secondary.args = {
type: 'secondary',
};

View file

@ -0,0 +1,70 @@
<template functional>
<component
:is="$options.components.ElMenu"
:defaultActive="props.defaultActive"
:collapse="props.collapse"
:class="$style[props.type + (props.light ? '-light' : '')]"
@select="listeners.select"
>
<slot></slot>
</component>
</template>
<script lang="ts">
import ElMenu from 'element-ui/lib/menu';
export default {
name: 'n8n-menu',
props: {
type: {
type: String,
default: 'primary',
validator: (value: string): boolean => ['primary', 'secondary'].includes(value),
},
defaultActive: {
type: String,
},
collapse: {
type: Boolean,
},
light: {
type: Boolean,
},
},
components: {
ElMenu,
},
};
</script>
<style lang="scss" module>
.menu {
max-width: 200px;
}
.primary {
composes: menu;
}
.secondary {
composes: menu;
--menu-font-color: var(--color-text-base);
--menu-item-font-color: var(--font-weight-regular);
--menu-background-color: transparent;
--menu-item-hover-font-color: var(--color-primary);
--menu-item-active-font-color: var(--color-text-dark);
--menu-item-active-background-color: var(--color-foreground-base);
--menu-item-border-radius: 4px;
--menu-item-height: 38px;
}
.secondary-light {
composes: secondary;
--menu-item-active-background-color: hsl(
var(--color-foreground-base-h),
var(--color-foreground-base-s),
var(--color-foreground-base-l),
0.7
);
}
</style>

View file

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

View file

@ -0,0 +1,7 @@
<script lang="ts">
import ElMenuItem from 'element-ui/lib/menu-item';
ElMenuItem.name = 'n8n-menu-item';
export default ElMenuItem;
</script>

View file

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

View file

@ -121,7 +121,7 @@ const LimitedWidthTemplate = (args, { argTypes }) => ({
N8nSelect,
N8nOption,
},
template: '<div style="width:100px;"><n8n-select v-bind="$props" v-model="val" @input="onInput" @change="onChange"><n8n-option value="1">op1</n8n-option><n8n-option value="2">op2 test very long ipsum lipsum test jskdfjsld kjfdslk jfdslkfj lksdjf</n8n-option></n8n-select></div>',
template: '<div style="width:100px;"><n8n-select v-bind="$props" v-model="val" @input="onInput" @change="onChange"><n8n-option value="1" label="opt1 11 1111" /><n8n-option value="2" label="opt2 test very long ipsum"/></n8n-select></div>',
data() {
return {
val: '',

View file

@ -2,22 +2,28 @@ import N8nButton from './N8nButton';
import N8nIcon from './N8nIcon';
import N8nIconButton from './N8nIconButton';
import N8nInput from './N8nInput';
import N8nInputLabel from './N8nInputLabel';
import N8nInfoTip from './N8nInfoTip';
import N8nInputNumber from './N8nInputNumber';
import N8nOption from './N8nOption';
import N8nInputLabel from './N8nInputLabel';
import N8nMenu from './N8nMenu';
import N8nMenuItem from './N8nMenuItem';
import N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner';
import N8nTooltip from './N8nTooltip';
import N8nOption from './N8nOption';
export {
N8nButton,
N8nIcon,
N8nIconButton,
N8nInfoTip,
N8nInput,
N8nInputLabel,
N8nInputNumber,
N8nOption,
N8nMenu,
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nTooltip,
N8nOption,
};

View file

@ -0,0 +1,5 @@
export function addTargetBlank(html: string) {
return html.includes('href=')
? html.replace(/href=/g, 'target="_blank" href=')
: html;
}

View file

@ -4,4 +4,6 @@ declare module 'element-ui/lib/tooltip';
declare module 'element-ui/lib/input-number';
declare module 'element-ui/lib/select';
declare module 'element-ui/lib/option';
declare module 'element-ui/lib/menu';
declare module 'element-ui/lib/menu-item';

View file

@ -53,7 +53,7 @@
);
--color-success-h: 150.4;
--color-success-s: 73.8%;
--color-success-s: 60%;
--color-success-l: 40.4%;
--color-success: hsl(
var(--color-success-h),

View file

@ -5,7 +5,6 @@
@include mixins.b(checkbox) {
color: var.$checkbox-font-color;
font-weight: var.$checkbox-font-weight;
font-size: var.$font-size-base;
position: relative;
cursor: pointer;
@ -156,10 +155,6 @@
transform: rotate(45deg) scaleY(1);
}
}
& + .el-checkbox__label {
color: var.$checkbox-checked-font-color;
}
}
@include mixins.when(focus) {
/*focus时 视觉上区分*/
@ -253,7 +248,6 @@
@include mixins.e(inner) {
display: inline-block;
line-height: 1;
font-weight: var.$checkbox-font-weight;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;

View file

@ -12,16 +12,20 @@
@keyframes v-modal-in {
0% {
opacity: 0;
backdrop-filter: blur(4px) opacity(0);
}
100% {
backdrop-filter: blur(4px) opacity(1);
}
}
@keyframes v-modal-out {
0% {
backdrop-filter: blur(4px) opacity(1);
}
100% {
opacity: 0;
backdrop-filter: blur(4px) opacity(0);
}
}
@ -31,8 +35,8 @@
top: 0;
width: 100%;
height: 100%;
opacity: var.$popup-modal-opacity;
background: var.$popup-modal-background-color;
background-color: var.$popup-modal-background-color;
backdrop-filter: blur(4px) opacity(1);
}
@include mixins.b(popup-parent) {

View file

@ -277,10 +277,8 @@ $icon-color-base: var(--color-info);
-------------------------- */
/// fontSize||Font|1
$checkbox-font-size: 14px;
/// fontWeight||Font|1
$checkbox-font-weight: $font-weight-primary;
/// color||Color|0
$checkbox-font-color: var(--color-text-dark);
$checkbox-font-color: var(--color-text-base);
$checkbox-input-height: 14px;
$checkbox-input-width: 14px;
/// borderRadius||Border|2
@ -761,7 +759,8 @@ $dialog-content-font-size: 14px;
/// fontLineHeight||LineHeight|2
$dialog-font-line-height: $font-line-height-primary;
/// padding||Spacing|3
$dialog-padding-primary: 20px;
$dialog-padding-primary: var(--spacing-l);
$dialog-close-top: var(--dialog-close-top, var(--spacing-l));
/* Table
-------------------------- */
@ -804,9 +803,9 @@ $pagination-hover-color: var(--color-primary);
/* Popup
-------------------------- */
/// color||Color|0
$popup-modal-background-color: $color-black;
$popup-modal-background-color: hsla(247,14%, 70%, 0.75);
/// opacity||Other|1
$popup-modal-opacity: 0.5;
$popup-modal-opacity: 0.65;
/* Popover
-------------------------- */
@ -852,9 +851,9 @@ $tag-warning-color: var(--color-warning);
/// color||Color|0
$tag-danger-color: var(--color-danger);
/// fontSize||Font|1
$tag-font-size: 12px;
$tag-font-size: var(--font-size-s);
$tag-border-radius: 4px;
$tag-padding: 0 10px;
$tag-padding: 16px;
/* Tree
-------------------------- */
@ -924,11 +923,17 @@ $steps-padding: 20px;
--------------------------*/
/// fontSize||Font|1
$menu-item-font-size: $font-size-base;
$menu-item-font-weight: var(--menu-item-font-color, 300);
/// color||Color|0
$menu-item-font-color: var(--color-text-dark);
$menu-item-font-color: var(--menu-font-color, var(--color-text-dark));
/// color||Color|0
$menu-background-color: $color-white;
$menu-item-hover-fill: $color-primary-light-9;
$menu-background-color: var(--menu-background-color, var(--color-background-xlight));
$menu-item-hover-fill: var(--menu-item-hover-fill, transparent);
$menu-item-hover-font-color: var(--menu-item-hover-font-color, var(--color-text-dark));
$menu-item-active-font-color: var(--menu-item-active-font-color, var(--color-primary));
$menu-item-active-background-color: var(--menu-item-active-background-color, transparent);
$menu-item-border-radius: var(--menu-item-border-radius, 0);
$menu-item-height: var(--menu-item-height, 56px);
/* Rate
--------------------------*/

View file

@ -32,12 +32,12 @@
@include mixins.e(header) {
padding: var.$dialog-padding-primary;
padding-bottom: 10px;
padding-bottom: 0px;
}
@include mixins.e(headerbtn) {
position: absolute;
top: var.$dialog-padding-primary;
top: var.$dialog-close-top;
right: var.$dialog-padding-primary;
padding: 0;
background: transparent;
@ -65,8 +65,8 @@
}
@include mixins.e(body) {
padding: (var.$dialog-padding-primary + 10px) var.$dialog-padding-primary;
color: var(--color-text-dark);
padding: var.$dialog-padding-primary;
color: var(--color-text-base);
font-size: var.$dialog-content-font-size;
word-break: break-all;
}

View file

@ -113,8 +113,7 @@ $directions: rtl, ltr, ttb, btt;
background-color: var.$dialog-background-color;
display: flex;
flex-direction: column;
box-shadow: 0 8px 10px -5px rgba(0, 0, 0, 0.2),
0 16px 24px 2px rgba(0, 0, 0, 0.14), 0 6px 30px 5px rgba(0, 0, 0, 0.12);
box-shadow: 0px 6px 16px rgb(68 28 23 / 6%);
overflow: hidden;
outline: 0;
@ -183,10 +182,12 @@ $directions: rtl, ltr, ttb, btt;
&.ltr {
left: 0;
border-right: var(--border-base);
}
&.rtl {
right: 0;
border-left: var(--border-base);
}
&.ttb {

View file

@ -4,10 +4,11 @@
@use "common/transition";
@mixin menu-item {
height: 56px;
line-height: 56px;
height: var.$menu-item-height;
line-height: var.$menu-item-height;
font-size: var.$menu-item-font-size;
color: var.$menu-item-font-color;
font-weight: var.$menu-item-font-weight;
padding: 0 20px;
list-style: none;
cursor: pointer;
@ -15,10 +16,7 @@
transition: border-color 0.3s, background-color 0.3s, color 0.3s;
box-sizing: border-box;
white-space: nowrap;
* {
vertical-align: middle;
}
border-radius: var.$menu-item-border-radius;
i {
color: var(--color-text-light);
@ -28,6 +26,7 @@
&:focus {
outline: none;
background-color: var.$menu-item-hover-fill;
color: var.$menu-item-hover-font-color;
}
@include mixins.when(disabled) {
@ -38,7 +37,6 @@
}
@include mixins.b(menu) {
border-right: solid 1px #e6e6e6;
list-style: none;
position: relative;
margin: 0;
@ -208,7 +206,9 @@
vertical-align: middle;
}
@include mixins.when(active) {
color: var(--color-primary);
color: var.$menu-item-active-font-color;
background-color: var.$menu-item-active-background-color;
i {
color: inherit;
}

View file

@ -226,7 +226,7 @@ hr {
height: 1px;
border: 0;
border-top: 1px solid var(--color-foreground-light);
margin: 1em 0;
margin: 0;
padding: 0;
}

View file

@ -15,6 +15,10 @@
.el-select__tags {
overflow-x: scroll;
&::-webkit-scrollbar {
display: none;
}
}
.el-select__tags > span {

View file

@ -5,6 +5,7 @@
background-color: var.$color-primary-lighter;
border-color: var.$color-primary-light-5;
color: var.$color-primary-light-1;
font-weight: var(--font-weight-regular);
@include mixins.when(hit) {
border-color: var.$tag-primary-color;
@ -37,9 +38,9 @@
}
&.el-tag--success {
background-color: var.$color-success-lighter;
border-color: var.$color-success-light-5;
color: var.$color-success-light-3;
background-color: var(--color-success-tint-2);
border-color: var(--color-success-tint-1);
color: var(--color-success);
@include mixins.when(hit) {
border-color: var.$tag-success-color;
@ -94,9 +95,7 @@
@include mixins.b(tag) {
@include genTheme();
display: inline-block;
height: 32px;
padding: var.$tag-padding;
line-height: 30px;
font-size: var.$tag-font-size;
color: var.$tag-primary-color;
border-width: 1px;
@ -132,8 +131,7 @@
}
@include mixins.m(medium) {
height: 28px;
line-height: 26px;
padding: 12px;
.el-icon-close {
transform: scale(0.8);

View file

@ -7,10 +7,6 @@
outline-width: 0;
}
a {
font-weight: var(--font-weight-bold);
}
@include mixins.e(popper) {
position: absolute;
border-radius: 4px;
@ -20,6 +16,11 @@
line-height: 1.2;
min-width: 10px;
word-wrap: break-word;
font-weight: var(--font-weight-regular);
a {
font-weight: var(--font-weight-bold);
}
.popper__arrow,
.popper__arrow::after {

View file

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "0.104.0",
"version": "0.107.1",
"description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -25,13 +25,13 @@
"test:unit": "vue-cli-service test:unit"
},
"dependencies": {
"n8n-design-system": "~0.1.0",
"@fontsource/open-sans": "^4.5.0",
"n8n-design-system": "~0.3.0",
"timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2",
"vue-fragment": "^1.5.2"
},
"devDependencies": {
"@beyonk/google-fonts-webpack-plugin": "^1.5.0",
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/vue-fontawesome": "^2.0.2",
@ -71,7 +71,7 @@
"lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"n8n-workflow": "~0.66.0",
"n8n-workflow": "~0.70.0",
"sass": "^1.26.5",
"normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1",

View file

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View file

@ -13,6 +13,7 @@ import {
INodeParameters,
INodePropertyOptions,
INodeTypeDescription,
INodeTypeNameVersion,
IRunExecutionData,
IRun,
IRunData,
@ -129,9 +130,9 @@ export interface IRestApi {
stopCurrentExecution(executionId: string): Promise<IExecutionsStopData>;
makeRestApiRequest(method: string, endpoint: string, data?: any): Promise<any>; // tslint:disable-line:no-any
getSettings(): Promise<IN8nUISettings>;
getNodeTypes(): Promise<INodeTypeDescription[]>;
getNodesInformation(nodeList: string[]): Promise<INodeTypeDescription[]>;
getNodeParameterOptions(nodeType: string, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
getNodeTypes(onlyLatest?: boolean): Promise<INodeTypeDescription[]>;
getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise<INodeTypeDescription[]>;
getNodeParameterOptions(nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
removeTestWebhook(workflowId: string): Promise<boolean>;
runWorkflow(runData: IStartRunData): Promise<IExecutionPushResponse>;
createNewWorkflow(sendData: IWorkflowDataUpdate): Promise<IWorkflowDb>;
@ -140,19 +141,10 @@ export interface IRestApi {
getWorkflow(id: string): Promise<IWorkflowDb>;
getWorkflows(filter?: object): Promise<IWorkflowShortResponse[]>;
getWorkflowFromUrl(url: string): Promise<IWorkflowDb>;
createNewCredentials(sendData: ICredentialsDecrypted): Promise<ICredentialsResponse>;
deleteCredentials(id: string): Promise<void>;
updateCredentials(id: string, data: ICredentialsDecrypted): Promise<ICredentialsResponse>;
getAllCredentials(filter?: object): Promise<ICredentialsResponse[]>;
getCredentials(id: string, includeData?: boolean): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined>;
getCredentialTypes(): Promise<ICredentialType[]>;
getExecution(id: string): Promise<IExecutionResponse>;
deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void>;
retryExecution(id: string, loadWorkflow?: boolean): Promise<boolean>;
getTimezones(): Promise<IDataObject>;
oAuth1CredentialAuthorize(sendData: ICredentialsResponse): Promise<string>;
oAuth2CredentialAuthorize(sendData: ICredentialsResponse): Promise<string>;
oAuth2Callback(code: string, state: string): Promise<string>;
}
export interface IBinaryDisplayData {
@ -163,13 +155,6 @@ export interface IBinaryDisplayData {
runIndex: number;
}
export interface ICredentialsCreatedEvent {
data: ICredentialsDecryptedResponse;
options: {
closeDialog: boolean,
};
}
export interface IStartRunData {
workflowData: IWorkflowData;
startNodes?: string[];
@ -585,8 +570,6 @@ export interface IRootState {
activeActions: string[];
activeNode: string | null;
baseUrl: string;
credentials: ICredentialsResponse[] | null;
credentialTypes: ICredentialType[] | null;
endpointWebhook: string;
endpointWebhookTest: string;
executionId: string | null;
@ -595,7 +578,6 @@ export interface IRootState {
pushConnectionActive: boolean;
saveDataErrorExecution: string;
saveDataSuccessExecution: string;
saveManualExecutions: boolean;
timezone: string;
stateIsDirty: boolean;
executionTimeout: number;
@ -618,6 +600,19 @@ export interface IRootState {
instanceId: string;
}
export interface ICredentialTypeMap {
[name: string]: ICredentialType;
}
export interface ICredentialMap {
[name: string]: ICredentialsResponse;
}
export interface ICredentialsState {
credentialTypes: ICredentialTypeMap;
credentials: ICredentialMap;
}
export interface ITagsState {
tags: { [id: string]: ITag };
isLoading: boolean;
@ -627,6 +622,8 @@ export interface ITagsState {
export interface IModalState {
open: boolean;
mode?: string | null;
activeId?: string | null;
}
export interface IUiState {

View file

@ -0,0 +1,53 @@
import { ICredentialsDecryptedResponse, ICredentialsResponse, IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from './helpers';
import {
ICredentialsDecrypted,
ICredentialType,
IDataObject,
NodeCredentialTestRequest,
NodeCredentialTestResult,
} from 'n8n-workflow';
export async function getCredentialTypes(context: IRestApiContext): Promise<ICredentialType[]> {
return await makeRestApiRequest(context, 'GET', '/credential-types');
}
export async function getCredentialsNewName(context: IRestApiContext, name?: string): Promise<{name: string}> {
return await makeRestApiRequest(context, 'GET', '/credentials/new', name ? { name } : {});
}
export async function getAllCredentials(context: IRestApiContext): Promise<ICredentialType[]> {
return await makeRestApiRequest(context, 'GET', '/credentials');
}
export async function createNewCredential(context: IRestApiContext, data: ICredentialsDecrypted): Promise<ICredentialsResponse> {
return makeRestApiRequest(context, 'POST', `/credentials`, data as unknown as IDataObject);
}
export async function deleteCredential(context: IRestApiContext, id: string): Promise<boolean> {
return makeRestApiRequest(context, 'DELETE', `/credentials/${id}`);
}
export async function updateCredential(context: IRestApiContext, id: string, data: ICredentialsDecrypted): Promise<ICredentialsResponse> {
return makeRestApiRequest(context, 'PATCH', `/credentials/${id}`, data as unknown as IDataObject);
}
export async function getCredentialData(context: IRestApiContext, id: string): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined> {
return makeRestApiRequest(context, 'GET', `/credentials/${id}`, {
includeData: true,
});
}
// Get OAuth1 Authorization URL using the stored credentials
export async function oAuth1CredentialAuthorize(context: IRestApiContext, data: ICredentialsResponse): Promise<string> {
return makeRestApiRequest(context, 'GET', `/oauth1-credential/auth`, data as unknown as IDataObject);
}
// Get OAuth2 Authorization URL using the stored credentials
export async function oAuth2CredentialAuthorize(context: IRestApiContext, data: ICredentialsResponse): Promise<string> {
return makeRestApiRequest(context, 'GET', `/oauth2-credential/auth`, data as unknown as IDataObject);
}
export async function testCredential(context: IRestApiContext, data: NodeCredentialTestRequest): Promise<NodeCredentialTestResult> {
return makeRestApiRequest(context, 'POST', '/credentials-test', data as unknown as IDataObject);
}

View file

@ -0,0 +1,147 @@
<template>
<el-tag
:type="theme"
size="medium"
:disable-transitions="true"
:class="$style.container"
>
<font-awesome-icon
:icon="theme === 'success' ? 'check-circle' : 'exclamation-triangle'"
:class="theme === 'success' ? $style.icon : $style.dangerIcon"
/>
<div
:class="$style.banner"
>
<div :class="$style.content">
<div>
<span
:class="theme === 'success' ? $style.message : $style.dangerMessage"
>
{{ message }}&nbsp;
</span>
<a v-if="details && !expanded" :class="$style.expandButton" @click="expand">More details</a>
</div>
</div>
<n8n-button
v-if="buttonLabel"
:label="buttonLoading && buttonLoadingLabel ? buttonLoadingLabel : buttonLabel"
:title="buttonTitle"
:theme="theme"
:loading="buttonLoading"
size="small"
type="outline"
:transparentBackground="true"
@click.stop="onClick"
/>
</div>
<div v-if="expanded" :class="$style.details">
{{details}}
</div>
</el-tag>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'Banner',
data() {
return {
expanded: false,
};
},
props: {
theme: {
type: String,
validator: (value: string): boolean =>
['success', 'danger'].indexOf(value) !== -1,
},
message: {
type: String,
},
buttonLabel: {
type: String,
},
buttonLoadingLabel: {
type: String,
},
buttonTitle: {
type: String,
},
details: {
type: String,
},
buttonLoading: {
type: Boolean,
default: false,
},
},
methods: {
expand() {
this.expanded = true;
},
onClick() {
this.expanded = false;
this.$emit('click');
},
},
});
</script>
<style module lang="scss">
.icon {
position: absolute;
left: 14px;
top: 18px;
}
.dangerIcon {
composes: icon;
color: var(--color-danger);
}
.container {
width: 100%;
position: relative;
padding-left: 40px;
border: none;
}
.message {
white-space: normal;
line-height: var(--font-line-height-regular);
overflow: hidden;
word-break: break-word;
}
.dangerMessage {
composes: message;
color: var(--color-danger);
}
.banner {
display: flex;
align-items: center;
}
.content {
flex-grow: 1;
min-height: 26px;
display: flex;
align-items: center;
}
.expandButton {
font-weight: var(--font-weight-bold);
}
.details {
composes: message;
margin-top: var(--spacing-3xs);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
}
</style>

View file

@ -0,0 +1,96 @@
<template>
<div>
<n8n-input-label :label="label">
<div :class="$style.copyText" @click="copy">
<span>{{ copyContent }}</span>
<div :class="$style.copyButton"><span>{{ copyButtonText }}</span></div>
</div>
</n8n-input-label>
<div :class="$style.subtitle">{{ subtitle }}</div>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { copyPaste } from './mixins/copyPaste';
import { showMessage } from './mixins/showMessage';
export default mixins(copyPaste, showMessage).extend({
props: {
label: {
type: String,
},
subtitle: {
type: String,
},
copyContent: {
type: String,
},
copyButtonText: {
type: String,
},
successMessage: {
type: String,
},
},
methods: {
copy(): void {
this.copyToClipboard(this.$props.copyContent);
this.$showMessage({
title: 'Copied',
message: this.$props.successMessage,
type: 'success',
});
},
},
});
</script>
<style lang="scss" module>
.copyText {
span {
font-family: Monaco, Consolas;
line-height: 1.5;
}
padding: var(--spacing-xs);
background-color: var(--color-background-light);
border: var(--border-base);
border-radius: var(--border-radius-base);
cursor: pointer;
position: relative;
font-weight: var(--font-weight-regular);
&:hover {
--display-copy-button: flex;
width: 100%;
}
}
.copyButton {
display: var(--display-copy-button, none);
position: absolute;
top: 0;
right: 0;
padding: var(--spacing-xs);
background-color: var(--color-background-light);
height: 100%;
align-items: center;
border-radius: var(--border-radius-base);
span {
font-family: unset;
}
}
.subtitle {
margin-top: var(--spacing-2xs);
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-loose);
font-weight: var(--font-weight-regular);
word-break: normal;
}
</style>

View file

@ -0,0 +1,189 @@
<template>
<div :class="$style.container">
<banner
v-show="showValidationWarning"
theme="danger"
message="Please check the errors below"
/>
<banner
v-if="authError && !showValidationWarning"
theme="danger"
message="Couldnt connect with these settings"
:details="authError"
buttonLabel="Retry"
buttonLoadingLabel="Retrying"
buttonTitle="Retry credentials test"
:buttonLoading="isRetesting"
@click="$emit('retest')"
/>
<banner
v-show="showOAuthSuccessBanner && !showValidationWarning"
theme="success"
message="Account connected"
buttonLabel="Reconnect"
buttonTitle="Reconnect OAuth Credentials"
@click="$emit('oauth')"
/>
<banner
v-show="testedSuccessfully && !showValidationWarning"
theme="success"
message="Connection tested successfully"
buttonLabel="Retry"
buttonLoadingLabel="Retrying"
buttonTitle="Retry credentials test"
:buttonLoading="isRetesting"
@click="$emit('retest')"
/>
<n8n-info-tip v-if="documentationUrl && credentialProperties.length">
Need help filling out these fields?
<a :href="documentationUrl" target="_blank">Open docs</a>
</n8n-info-tip>
<CopyInput
v-if="isOAuthType && credentialProperties.length"
label="OAuth Redirect URL"
:copyContent="oAuthCallbackUrl"
copyButtonText="Click to copy"
:subtitle="`In ${appName}, use the URL above when prompted to enter an OAuth callback or redirect URL`"
successMessage="Redirect URL copied to clipboard"
/>
<CredentialInputs
v-if="credentialType"
:credentialData="credentialData"
:credentialProperties="credentialProperties"
:documentationUrl="documentationUrl"
:showValidationWarnings="showValidationWarning"
@change="onDataChange"
/>
<OauthButton
v-if="isOAuthType && requiredPropertiesFilled && !isOAuthConnected"
:isGoogleOAuthType="isGoogleOAuthType"
@click="$emit('oauth')"
/>
</div>
</template>
<script lang="ts">
import { ICredentialType } from 'n8n-workflow';
import { getAppNameFromCredType } from '../helpers';
import Vue from 'vue';
import Banner from '../Banner.vue';
import CopyInput from '../CopyInput.vue';
import CredentialInputs from './CredentialInputs.vue';
import OauthButton from './OauthButton.vue';
export default Vue.extend({
name: 'CredentialConfig',
components: {
Banner,
CopyInput,
CredentialInputs,
OauthButton,
},
props: {
credentialType: {
},
credentialProperties: {
type: Array,
},
parentTypes: {
type: Array,
},
credentialData: {
},
showValidationWarning: {
type: Boolean,
default: false,
},
authError: {
type: String,
},
testedSuccessfully: {
type: Boolean,
},
isOAuthType: {
type: Boolean,
},
isOAuthConnected: {
type: Boolean,
},
isRetesting: {
type: Boolean,
},
requiredPropertiesFilled: {
type: Boolean,
},
},
computed: {
appName(): string {
if (!this.credentialType) {
return '';
}
const appName = getAppNameFromCredType(
(this.credentialType as ICredentialType).displayName,
);
return appName || "the service you're connecting to";
},
credentialTypeName(): string {
return (this.credentialType as ICredentialType).name;
},
documentationUrl(): string {
const type = this.credentialType as ICredentialType;
if (!type || !type.documentationUrl) {
return '';
}
if (type.documentationUrl.startsWith('https://') || type.documentationUrl.startsWith('http://')) {
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`;
},
isGoogleOAuthType(): boolean {
return this.credentialTypeName === 'googleOAuth2Api' || this.parentTypes.includes('googleOAuth2Api');
},
oAuthCallbackUrl(): string {
const oauthType =
this.credentialTypeName === 'oAuth2Api' ||
this.parentTypes.includes('oAuth2Api')
? 'oauth2'
: 'oauth1';
return this.$store.getters.oauthCallbackUrls[oauthType];
},
showOAuthSuccessBanner(): boolean {
return this.isOAuthType && this.requiredPropertiesFilled && this.isOAuthConnected && !this.authError;
},
},
methods: {
onDataChange(event: { name: string; value: string | number | boolean | Date | null }): void {
this.$emit('change', event);
},
},
watch: {
showOAuthSuccessBanner(newValue, oldValue) {
if (newValue && !oldValue) {
this.$emit('scrollToTop');
}
},
},
});
</script>
<style lang="scss" module>
.container {
> * {
margin-bottom: var(--spacing-l);
}
}
</style>

View file

@ -0,0 +1,854 @@
<template>
<Modal
:name="modalName"
:customClass="$style.credentialModal"
:eventBus="modalBus"
:loading="loading"
:beforeClose="beforeClose"
width="70%"
height="80%"
>
<template slot="header">
<div v-if="credentialType" :class="$style.header">
<div :class="$style.credInfo">
<div :class="$style.credIcon">
<CredentialIcon :credentialTypeName="credentialTypeName" />
</div>
<InlineNameEdit
:name="credentialName"
:subtitle="credentialType.displayName"
type="Credential"
@input="onNameEdit"
/>
</div>
<div :class="$style.credActions">
<n8n-icon-button
v-if="currentCredential"
size="medium"
title="Delete"
icon="trash"
type="text"
:disabled="isSaving"
:loading="isDeleting"
@click="deleteCredential"
/>
<SaveButton
v-if="hasUnsavedChanges || credentialId"
:saved="!hasUnsavedChanges && !isTesting"
:isSaving="isSaving || isTesting"
:savingLabel="isTesting ? 'Testing' : 'Saving'"
@click="saveCredential"
/>
</div>
</div>
<hr />
</template>
<template slot="content">
<div :class="$style.container">
<div :class="$style.sidebar">
<n8n-menu
type="secondary"
@select="onTabSelect"
defaultActive="connection"
:light="true"
>
<n8n-menu-item index="connection" :class="$style.credTab"
><span slot="title">Connection</span></n8n-menu-item
>
<n8n-menu-item index="details" :class="$style.credTab"
><span slot="title">Details</span></n8n-menu-item
>
</n8n-menu>
</div>
<div v-if="activeTab === 'connection'" :class="$style.mainContent" ref="content">
<CredentialConfig
:credentialType="credentialType"
:credentialProperties="credentialProperties"
:credentialData="credentialData"
:showValidationWarning="showValidationWarning"
:authError="authError"
:testedSuccessfully="testedSuccessfully"
:isOAuthType="isOAuthType"
:isOAuthConnected="isOAuthConnected"
:isRetesting="isRetesting"
:parentTypes="parentTypes"
:requiredPropertiesFilled="requiredPropertiesFilled"
@change="onDataChange"
@oauth="oAuthCredentialAuthorize"
@retest="retestCredential"
@scrollToTop="scrollToTop"
/>
</div>
<div v-if="activeTab === 'details'" :class="$style.mainContent">
<CredentialInfo
:nodeAccess="nodeAccess"
:nodesWithAccess="nodesWithAccess"
:currentCredential="currentCredential"
@accessChange="onNodeAccessChange"
/>
</div>
</div>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import {
ICredentialsDecryptedResponse,
ICredentialsResponse,
} from '@/Interface';
import {
CredentialInformation,
ICredentialDataDecryptedObject,
ICredentialNodeAccess,
ICredentialsDecrypted,
ICredentialType,
INodeParameters,
INodeProperties,
INodeTypeDescription,
NodeCredentialTestResult,
NodeHelpers,
} from 'n8n-workflow';
import CredentialIcon from '../CredentialIcon.vue';
import mixins from 'vue-typed-mixins';
import { nodeHelpers } from '../mixins/nodeHelpers';
import { showMessage } from '../mixins/showMessage';
import CredentialConfig from './CredentialConfig.vue';
import CredentialInfo from './CredentialInfo.vue';
import SaveButton from '../SaveButton.vue';
import Modal from '../Modal.vue';
import InlineNameEdit from '../InlineNameEdit.vue';
interface NodeAccessMap {
[nodeType: string]: ICredentialNodeAccess | null;
}
export default mixins(showMessage, nodeHelpers).extend({
name: 'CredentialsDetail',
components: {
CredentialConfig,
CredentialIcon,
CredentialInfo,
InlineNameEdit,
Modal,
SaveButton,
},
props: {
modalName: {
type: String,
required: true,
},
activeId: {
type: String,
required: true,
},
mode: {
type: String,
},
},
data() {
return {
activeTab: 'connection',
authError: '',
credentialId: '',
credentialName: '',
credentialData: {} as ICredentialDataDecryptedObject,
modalBus: new Vue(),
nodeAccess: {} as NodeAccessMap,
isDeleting: false,
isSaving: false,
isTesting: false,
hasUnsavedChanges: false,
loading: true,
showValidationWarning: false,
testedSuccessfully: false,
isRetesting: false,
};
},
async mounted() {
this.nodeAccess = this.nodesWithAccess.reduce(
(accu: NodeAccessMap, node: { name: string }) => {
if (this.mode === 'new') {
accu[node.name] = { nodeType: node.name }; // enable all nodes by default
} else {
accu[node.name] = null;
}
return accu;
},
{},
);
if (this.mode === 'new') {
this.credentialName = await this.$store.dispatch(
'credentials/getNewCredentialName',
{ credentialTypeName: this.credentialTypeName },
);
} else {
await this.loadCurrentCredential();
}
if (this.credentialType) {
for (const property of this.credentialType.properties) {
if (!this.credentialData.hasOwnProperty(property.name)) {
this.credentialData[property.name] =
property.default as CredentialInformation;
}
}
}
if (this.credentialId) {
if (!this.requiredPropertiesFilled) {
this.showValidationWarning = true;
}
else {
this.retestCredential();
}
}
this.loading = false;
},
computed: {
currentCredential(): ICredentialsResponse | null {
if (!this.credentialId) {
return null;
}
return this.$store.getters['credentials/getCredentialById'](
this.credentialId,
);
},
credentialTypeName(): string | null {
if (this.mode === 'edit') {
if (this.currentCredential) {
return this.currentCredential.type;
}
return null;
}
return this.activeId;
},
credentialType(): ICredentialType | null {
if (!this.credentialTypeName) {
return null;
}
const type = this.$store.getters['credentials/getCredentialTypeByName'](
this.credentialTypeName,
);
return {
...type,
properties: this.getCredentialProperties(this.credentialTypeName),
};
},
isCredentialTestable (): boolean {
if (this.isOAuthType || !this.requiredPropertiesFilled) {
return false;
}
const hasExpressions = Object.values(this.credentialData).reduce((accu: boolean, value: CredentialInformation) => accu || (typeof value === 'string' && value.startsWith('=')), false);
if (hasExpressions) {
return false;
}
const nodesThatCanTest = this.nodesWithAccess.filter(node => {
if (node.credentials) {
// Returns a list of nodes that can test this credentials
const eligibleTesters = node.credentials.filter(credential => {
return credential.name === this.credentialTypeName && credential.testedBy;
});
// If we have any node that can test, return true.
return !!eligibleTesters.length;
}
return false;
});
return !!nodesThatCanTest.length;
},
nodesWithAccess(): INodeTypeDescription[] {
if (this.credentialTypeName) {
return this.$store.getters['credentials/getNodesWithAccess'](
this.credentialTypeName,
);
}
return [];
},
parentTypes(): string[] {
if (this.credentialTypeName) {
return this.getParentTypes(this.credentialTypeName);
}
return [];
},
isOAuthType(): boolean {
return !!this.credentialTypeName && (
['oAuth1Api', 'oAuth2Api'].includes(this.credentialTypeName) ||
this.parentTypes.includes('oAuth1Api') ||
this.parentTypes.includes('oAuth2Api')
);
},
isOAuthConnected(): boolean {
return this.isOAuthType && !!this.credentialData.oauthTokenData;
},
credentialProperties(): INodeProperties[] {
if (!this.credentialType) {
return [];
}
return this.credentialType.properties.filter(
(propertyData: INodeProperties) => {
if (!this.displayCredentialParameter(propertyData)) {
return false;
}
return (
!this.credentialType!.__overwrittenProperties ||
!this.credentialType!.__overwrittenProperties.includes(
propertyData.name,
)
);
},
);
},
requiredPropertiesFilled(): boolean {
for (const property of this.credentialProperties) {
if (property.required !== true) {
continue;
}
if (!this.credentialData[property.name]) {
return false;
}
}
return true;
},
},
methods: {
async beforeClose(done: () => void) {
let keepEditing = false;
if (this.hasUnsavedChanges) {
const displayName = this.credentialType ? this.credentialType.displayName : '';
keepEditing = await this.confirmMessage(
`Are you sure you want to throw away the changes you made to the ${displayName} credential?`,
'Close without saving?',
null,
'Keep editing',
'Close',
);
}
else if (this.isOAuthType && !this.isOAuthConnected) {
keepEditing = await this.confirmMessage(
`You need to connect your credential for it to work`,
'Close without connecting?',
null,
'Keep editing',
'Close',
);
}
if (!keepEditing) {
done();
return;
}
else if (!this.requiredPropertiesFilled) {
this.showValidationWarning = true;
this.scrollToTop();
}
else if (this.isOAuthType) {
this.scrollToBottom();
}
},
displayCredentialParameter(parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') {
return false;
}
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
return this.displayParameter(
this.credentialData as INodeParameters,
parameter,
'',
);
},
getCredentialProperties(name: string): INodeProperties[] {
const credentialsData =
this.$store.getters['credentials/getCredentialTypeByName'](name);
if (!credentialsData) {
throw new Error(`Could not find credentials of type: ${name}`);
}
if (credentialsData.extends === undefined) {
return credentialsData.properties;
}
const combineProperties = [] as INodeProperties[];
for (const credentialsTypeName of credentialsData.extends) {
const mergeCredentialProperties =
this.getCredentialProperties(credentialsTypeName);
NodeHelpers.mergeNodeProperties(
combineProperties,
mergeCredentialProperties,
);
}
// The properties defined on the parent credentials take presidence
NodeHelpers.mergeNodeProperties(
combineProperties,
credentialsData.properties,
);
return combineProperties;
},
async loadCurrentCredential() {
this.credentialId = this.activeId;
try {
const currentCredentials: ICredentialsDecryptedResponse =
await this.$store.dispatch('credentials/getCredentialData', {
id: this.credentialId,
});
if (!currentCredentials) {
throw new Error(
`Could not find the credentials with the id: ${this.credentialId}`,
);
}
this.credentialData = currentCredentials.data || {};
this.credentialName = currentCredentials.name;
currentCredentials.nodesAccess.forEach(
(access: { nodeType: string }) => {
// keep node access structure to keep dates when updating
this.nodeAccess[access.nodeType] = access;
},
);
} catch (e) {
this.$showError(
e,
'Problem loading credentials',
'There was a problem loading the credentials:',
);
this.closeDialog();
return;
}
},
onTabSelect(tab: string) {
this.activeTab = tab;
},
onNodeAccessChange({name, value}: {name: string, value: boolean}) {
this.hasUnsavedChanges = true;
if (value) {
this.nodeAccess = {
...this.nodeAccess,
[name]: {
nodeType: name,
},
};
} else {
this.nodeAccess = {
...this.nodeAccess,
[name]: null,
};
}
},
onDataChange({ name, value }: { name: string; value: any }) { // tslint:disable-line:no-any
this.hasUnsavedChanges = true;
const { oauthTokenData, ...credData } = this.credentialData;
this.credentialData = {
...credData,
[name]: value,
};
},
closeDialog() {
this.modalBus.$emit('close');
},
getParentTypes(name: string): string[] {
const credentialType =
this.$store.getters['credentials/getCredentialTypeByName'](name);
if (
credentialType === undefined ||
credentialType.extends === undefined
) {
return [];
}
const types: string[] = [];
for (const typeName of credentialType.extends) {
types.push(typeName);
types.push.apply(types, this.getParentTypes(typeName));
}
return types;
},
onNameEdit(text: string) {
this.hasUnsavedChanges = true;
this.credentialName = text;
},
scrollToTop() {
setTimeout(() => {
const content = this.$refs.content as Element;
if (content) {
content.scrollTop = 0;
}
}, 0);
},
scrollToBottom() {
setTimeout(() => {
const content = this.$refs.content as Element;
if (content) {
content.scrollTop = content.scrollHeight;
}
}, 0);
},
async retestCredential() {
if (!this.isCredentialTestable) {
this.authError = '';
this.testedSuccessfully = false;
return;
}
const nodesAccess = Object.values(this.nodeAccess).filter(
(access) => !!access,
) as ICredentialNodeAccess[];
// Save only the none default data
const data = NodeHelpers.getNodeParameters(
this.credentialType!.properties,
this.credentialData as INodeParameters,
false,
false,
);
const details: ICredentialsDecrypted = {
name: this.credentialName,
type: this.credentialTypeName!,
data: data as unknown as ICredentialDataDecryptedObject,
nodesAccess,
};
this.isRetesting = true;
await this.testCredential(details);
this.isRetesting = false;
},
async testCredential(credentialDetails: ICredentialsDecrypted) {
const result: NodeCredentialTestResult = await this.$store.dispatch('credentials/testCredential', credentialDetails);
if (result.status === 'Error') {
this.authError = result.message;
this.testedSuccessfully = false;
}
else {
this.authError = '';
this.testedSuccessfully = true;
}
this.scrollToTop();
},
async saveCredential(): Promise<ICredentialsResponse | null> {
if (!this.requiredPropertiesFilled) {
this.showValidationWarning = true;
this.scrollToTop();
}
else {
this.showValidationWarning = false;
}
this.isSaving = true;
const nodesAccess = Object.values(this.nodeAccess).filter(
(access) => !!access,
) as ICredentialNodeAccess[];
// Save only the none default data
const data = NodeHelpers.getNodeParameters(
this.credentialType!.properties,
this.credentialData as INodeParameters,
false,
false,
);
const credentialDetails: ICredentialsDecrypted = {
name: this.credentialName,
type: this.credentialTypeName!,
data: data as unknown as ICredentialDataDecryptedObject,
nodesAccess,
};
let credential;
if (this.mode === 'new' && !this.credentialId) {
credential = await this.createCredential(
credentialDetails,
);
} else {
credential = await this.updateCredential(
credentialDetails,
);
}
this.isSaving = false;
if (credential) {
this.credentialId = credential.id as string;
if (this.isCredentialTestable) {
this.isTesting = true;
await this.testCredential(credentialDetails);
this.isTesting = false;
}
else {
this.authError = '';
this.testedSuccessfully = false;
}
}
return credential;
},
async createCredential(
credentialDetails: ICredentialsDecrypted,
): Promise<ICredentialsResponse | null> {
let credential;
try {
credential = (await this.$store.dispatch(
'credentials/createNewCredential',
credentialDetails,
)) as ICredentialsResponse;
this.hasUnsavedChanges = false;
} catch (error) {
this.$showError(
error,
'Problem creating credentials',
'There was a problem creating the credentials:',
);
return null;
}
this.$externalHooks().run('credentials.create', {
credentialTypeData: this.credentialData,
});
return credential;
},
async updateCredential(
credentialDetails: ICredentialsDecrypted,
): Promise<ICredentialsResponse | null> {
let credential;
try {
credential = (await this.$store.dispatch(
'credentials/updateCredential',
{ id: this.credentialId, data: credentialDetails },
)) as ICredentialsResponse;
this.hasUnsavedChanges = false;
} catch (error) {
this.$showError(
error,
'Problem updating credentials',
'There was a problem updating the credentials:',
);
return null;
}
// Now that the credentials changed check if any nodes use credentials
// which have now a different name
this.updateNodesCredentialsIssues();
return credential;
},
async deleteCredential() {
if (!this.currentCredential) {
return;
}
const savedCredentialName = this.currentCredential.name;
const deleteConfirmed = await this.confirmMessage(
`Are you sure you want to delete "${savedCredentialName}" credentials?`,
'Delete Credentials?',
null,
'Yes, delete!',
);
if (deleteConfirmed === false) {
return;
}
try {
this.isDeleting = true;
await this.$store.dispatch('credentials/deleteCredential', {
id: this.credentialId,
});
this.hasUnsavedChanges = false;
} catch (error) {
this.$showError(
error,
'Problem deleting credentials',
'There was a problem deleting the credentials:',
);
this.isDeleting = false;
return;
}
this.isDeleting = false;
// Now that the credentials were removed check if any nodes used them
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Credentials deleted',
message: `The credential "${savedCredentialName}" was deleted!`,
type: 'success',
});
this.closeDialog();
},
async oAuthCredentialAuthorize() {
let url;
const credential = await this.saveCredential();
if (!credential) {
return;
}
const types = this.parentTypes;
try {
if (
this.credentialTypeName === 'oAuth2Api' ||
types.includes('oAuth2Api')
) {
url = (await this.$store.dispatch('credentials/oAuth2Authorize', {
...this.credentialData,
id: credential.id,
})) as string;
} else if (
this.credentialTypeName === 'oAuth1Api' ||
types.includes('oAuth1Api')
) {
url = (await this.$store.dispatch('credentials/oAuth1Authorize', {
...this.credentialData,
id: credential.id,
})) as string;
}
} catch (error) {
this.$showError(
error,
'OAuth Authorization Error',
'Error generating authorization URL:',
);
return;
}
const params = `scrollbars=no,resizable=yes,status=no,titlebar=noe,location=no,toolbar=no,menubar=no,width=500,height=700`;
const oauthPopup = window.open(url, 'OAuth2 Authorization', params);
Vue.set(this.credentialData, 'oauthTokenData', null);
const receiveMessage = (event: MessageEvent) => {
// // TODO: Add check that it came from n8n
// if (event.origin !== 'http://example.org:8080') {
// return;
// }
if (event.data === 'success') {
window.removeEventListener('message', receiveMessage, false);
// Set some kind of data that status changes.
// As data does not get displayed directly it does not matter what data.
Vue.set(this.credentialData, 'oauthTokenData', {});
this.$store.commit('credentials/enableOAuthCredential', credential);
// Close the window
if (oauthPopup) {
oauthPopup.close();
}
}
};
window.addEventListener('message', receiveMessage, false);
},
},
});
</script>
<style module lang="scss">
.credentialModal {
max-width: 900px;
--dialog-close-top: 28px;
}
.mainContent {
flex-grow: 1;
overflow: auto;
padding-bottom: 100px;
}
.sidebar {
max-width: 170px;
min-width: 170px;
margin-right: var(--spacing-l);
flex-grow: 1;
}
.header {
display: flex;
}
.container {
display: flex;
height: 100%;
}
.credInfo {
display: flex;
flex-grow: 1;
margin-bottom: var(--spacing-s);
}
.credTab {
padding-left: 12px !important;
}
.credActions {
margin-right: var(--spacing-xl);
> * {
margin-left: var(--spacing-2xs);
}
}
.credIcon {
display: flex;
align-items: center;
margin-right: var(--spacing-xs);
}
</style>

View file

@ -0,0 +1,91 @@
<template>
<div :class="$style.container">
<el-row>
<el-col :span="8" :class="$style.accessLabel">
<span>Allow use by</span>
</el-col>
<el-col :span="16">
<div
v-for="node in nodesWithAccess"
:key="node.name"
:class="$style.valueLabel"
>
<el-checkbox
:label="node.displayName"
:value="!!nodeAccess[node.name]"
@change="(val) => onNodeAccessChange(node.name, val)"
/>
</div>
</el-col>
</el-row>
<el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label">
<span>Created</span>
</el-col>
<el-col :span="16" :class="$style.valueLabel">
<TimeAgo :date="currentCredential.createdAt" :capitalize="true" />
</el-col>
</el-row>
<el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label">
<span>Last modified</span>
</el-col>
<el-col :span="16" :class="$style.valueLabel">
<TimeAgo :date="currentCredential.updatedAt" :capitalize="true" />
</el-col>
</el-row>
<el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label">
<span>ID</span>
</el-col>
<el-col :span="16" :class="$style.valueLabel">
<span>{{currentCredential.id}}</span>
</el-col>
</el-row>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import TimeAgo from '../TimeAgo.vue';
export default Vue.extend({
name: 'CredentialInfo',
props: ['nodesWithAccess', 'nodeAccess', 'currentCredential'],
components: {
TimeAgo,
},
methods: {
onNodeAccessChange(name: string, value: string) {
this.$emit('accessChange', {
name,
value,
});
},
},
});
</script>
<style lang="scss" module>
.container {
> * {
margin-bottom: var(--spacing-l);
}
}
.label {
font-weight: var(--font-weight-bold);
max-width: 230px;
}
.accessLabel {
composes: label;
margin-top: var(--spacing-5xs);
}
.valueLabel {
font-weight: var(--font-weight-regular);
}
</style>

View file

@ -0,0 +1,52 @@
<template>
<div @keydown.stop :class="$style.container" v-if="credentialProperties.length">
<div v-for="parameter in credentialProperties" :key="parameter.name">
<ParameterInputExpanded
:parameter="parameter"
:value="credentialData[parameter.name]"
:documentationUrl="documentationUrl"
:showValidationWarnings="showValidationWarnings"
@change="valueChanged"
/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { IUpdateInformation } from '../../Interface';
import ParameterInputExpanded from '../ParameterInputExpanded.vue';
export default Vue.extend({
name: 'CredentialsInput',
props: [
'credentialProperties',
'credentialData', // ICredentialsDecryptedResponse
'documentationUrl',
'showValidationWarnings',
],
components: {
ParameterInputExpanded,
},
methods: {
valueChanged(parameterData: IUpdateInformation) {
const name = parameterData.name.split('.').pop();
this.$emit('change', {
name,
value: parameterData.value,
});
},
},
});
</script>
<style lang="scss" module>
.container {
> * {
margin-bottom: var(--spacing-l);
}
}
</style>

View file

@ -0,0 +1,41 @@
<template>
<span>
<img
v-if="isGoogleOAuthType"
:src="basePath + 'google-signin-light.png'"
:class="$style.googleIcon"
alt="Sign in with Google"
@click.stop="$emit('click')"
/>
<n8n-button
v-else
label="Connect my account"
size="large"
@click.stop="$emit('click')"
/>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
isGoogleOAuthType: {
type: Boolean,
},
},
computed: {
basePath(): string {
return this.$store.getters.getBaseUrl;
},
},
});
</script>
<style module lang="scss">
.googleIcon {
width: 191px;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,74 @@
<template>
<div>
<img v-if="filePath" :class="$style.credIcon" :src="filePath" />
<NodeIcon v-else-if="relevantNode" :nodeType="relevantNode" :size="28" />
</div>
</template>
<script lang="ts">
import { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import Vue from 'vue';
export default Vue.extend({
props: {
credentialTypeName: {
type: String,
required: true,
},
},
computed: {
credentialWithIcon(): ICredentialType | null {
return this.getCredentialWithIcon(this.credentialTypeName);
},
filePath(): string | null {
if (!this.credentialWithIcon || !this.credentialWithIcon.icon || !this.credentialWithIcon.icon.startsWith('file:')) {
return null;
}
const restUrl = this.$store.getters.getRestUrl;
return `${restUrl}/credential-icon/${this.credentialWithIcon.name}`;
},
relevantNode(): INodeTypeDescription | null {
if (this.credentialWithIcon && this.credentialWithIcon.icon && this.credentialWithIcon.icon.startsWith('node:')) {
const nodeType = this.credentialWithIcon.icon.replace('node:', '');
return this.$store.getters.nodeType(nodeType);
}
const nodesWithAccess = this.$store.getters['credentials/getNodesWithAccess'](this.credentialTypeName);
if (nodesWithAccess.length) {
return nodesWithAccess[0];
}
return null;
},
},
methods: {
getCredentialWithIcon(name: string): ICredentialType | null {
const type = this.$store.getters['credentials/getCredentialTypeByName'](name);
if (type.icon) {
return type;
}
if (type.extends) {
return type.extends.reduce((accu: string | null, type: string) => {
return accu || this.getCredentialWithIcon(type);
}, null);
}
return null;
},
},
});
</script>
<style lang="scss" module>
.credIcon {
height: 26px;
}
</style>

View file

@ -1,388 +0,0 @@
<template>
<div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" append-to-body width="75%" class="credentials-edit-wrapper" :title="title" :nodeType="nodeType" :before-close="closeDialog">
<div name="title" class="title-container" slot="title">
<div class="title-left">{{title}}</div>
<div class="title-right">
<div v-if="credentialType && documentationUrl" class="docs-container">
<svg class="help-logo" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Node Documentation</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
<g transform="translate(10.000000, 11.000000)">
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
</g>
<rect x="0" y="0" width="18" height="18"></rect>
</g>
</g>
</g>
</g>
</svg>
<span class="doc-link-text">Need help? <a class="doc-hyperlink" :href="documentationUrl" target="_blank">Open credential docs</a></span>
</div>
</div>
</div>
<div class="credential-type-item">
<el-row v-if="!setCredentialType">
<el-col :span="6">
Credential type:
</el-col>
<el-col :span="18">
<n8n-select v-model="credentialType" filterable placeholder="Select Type" size="medium" ref="credentialsDropdown">
<n8n-option
v-for="item in credentialTypes"
:key="item.name"
:label="item.displayName"
:value="item.name">
</n8n-option>
</n8n-select>
</el-col>
</el-row>
</div>
<credentials-input v-if="credentialType" @credentialsCreated="credentialsCreated" @credentialsUpdated="credentialsUpdated" :credentialTypeData="getCredentialTypeData(credentialType)" :credentialData="credentialData" :nodesInit="nodesInit"></credentials-input>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { externalHooks } from '@/components/mixins/externalHooks';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import CredentialsInput from '@/components/CredentialsInput.vue';
import {
ICredentialsCreatedEvent,
ICredentialsDecryptedResponse,
} from '@/Interface';
import {
NodeHelpers,
ICredentialType,
INodeProperties,
INodeTypeDescription,
} from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
import { INodeUi } from '../Interface';
export default mixins(
restApi,
showMessage,
externalHooks,
).extend({
name: 'CredentialsEdit',
props: [
'dialogVisible', // Boolean
'editCredentials',
'setCredentialType', // String
'nodesInit', // Array
],
components: {
CredentialsInput,
},
data () {
return {
credentialData: null as ICredentialsDecryptedResponse | null,
credentialType: null as string | null,
};
},
computed: {
credentialTypes (): ICredentialType[] {
const credentialTypes = this.$store.getters.allCredentialTypes;
if (credentialTypes === null) {
return [];
}
return credentialTypes;
},
title (): string {
if (this.editCredentials) {
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
return `Edit Credentials: "${credentialType.displayName}"`;
} else {
if (this.credentialType) {
const credentialType = this.$store.getters.credentialType(this.credentialType);
return `Create New Credentials: "${credentialType.displayName}"`;
} else {
return `Create New Credentials`;
}
}
},
documentationUrl (): string | undefined {
let credentialTypeName = '';
if (this.editCredentials) {
credentialTypeName = this.editCredentials.type as string;
} else {
credentialTypeName = this.credentialType as string;
}
const credentialType = this.$store.getters.credentialType(credentialTypeName);
if (credentialType.documentationUrl !== undefined) {
if (credentialType.documentationUrl.startsWith('http')) {
return credentialType.documentationUrl;
} else {
return 'https://docs.n8n.io/credentials/' + credentialType.documentationUrl + '/?utm_source=n8n_app&utm_medium=left_nav_menu&utm_campaign=create_new_credentials_modal';
}
}
return undefined;
},
node (): INodeUi {
return this.$store.getters.activeNode;
},
nodeType (): INodeTypeDescription | null {
const activeNode = this.node;
if (this.node) {
return this.$store.getters.nodeType(this.node.type);
}
return null;
},
},
watch: {
async dialogVisible (newValue, oldValue): Promise<void> {
if (newValue) {
if (this.editCredentials) {
// Credentials which should be edited are given
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
if (credentialType === null) {
this.$showMessage({
title: 'Credential type not known',
message: `Credentials of type "${this.editCredentials.type}" are not known.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
if (this.editCredentials.id === undefined) {
this.$showMessage({
title: 'Credential ID missing',
message: 'The ID of the credentials which should be edited is missing!',
type: 'error',
});
this.closeDialog();
return;
}
let currentCredentials: ICredentialsDecryptedResponse | undefined;
try {
currentCredentials = await this.restApi().getCredentials(this.editCredentials.id as string, true) as ICredentialsDecryptedResponse | undefined;
} catch (error) {
this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:');
this.closeDialog();
return;
}
if (currentCredentials === undefined) {
this.$showMessage({
title: 'Credentials not found',
message: `Could not find the credentials with the id: ${this.editCredentials.id}`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
if (currentCredentials === undefined) {
this.$showMessage({
title: 'Problem loading credentials',
message: 'No credentials could be loaded!',
type: 'error',
});
return;
}
this.credentialData = currentCredentials;
} else {
Vue.nextTick(() => {
(this.$refs.credentialsDropdown as HTMLDivElement).focus();
});
if (this.credentialType || this.setCredentialType) {
const credentialType = this.$store.getters.credentialType(this.credentialType || this.setCredentialType);
if (credentialType === null) {
this.$showMessage({
title: 'Credential type not known',
message: `Credentials of type "${this.credentialType || this.setCredentialType}" are not known.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
}
this.credentialData = null;
}
if (this.setCredentialType || (this.credentialData && this.credentialData.type)) {
this.credentialType = this.setCredentialType || (this.credentialData && this.credentialData.type);
}
} else {
// Make sure that it gets always reset else it uses by default
// again the last selection from when it was open the previous time.
this.credentialType = null;
}
},
async credentialType (newValue, oldValue) {
this.$externalHooks().run('credentialsEdit.credentialTypeChanged', { newValue, oldValue, editCredentials: !!this.editCredentials, credentialType: this.credentialType, setCredentialType: this.setCredentialType });
},
},
methods: {
getCredentialProperties (name: string): INodeProperties[] {
const credentialsData = this.$store.getters.credentialType(name);
if (credentialsData === null) {
throw new Error(`Could not find credentials of type: ${name}`);
}
if (credentialsData.extends === undefined) {
return credentialsData.properties;
}
const combineProperties = [] as INodeProperties[];
for (const credentialsTypeName of credentialsData.extends) {
const mergeCredentialProperties = this.getCredentialProperties(credentialsTypeName);
NodeHelpers.mergeNodeProperties(combineProperties, mergeCredentialProperties);
}
// The properties defined on the parent credentials take presidence
NodeHelpers.mergeNodeProperties(combineProperties, credentialsData.properties);
return combineProperties;
},
getCredentialTypeData (name: string): ICredentialType | null {
let credentialData = this.$store.getters.credentialType(name);
if (credentialData === null || credentialData.extends === undefined) {
return credentialData;
}
// Credentials extends another one. So get the properties of the one it
// extends and add them.
credentialData = JSON.parse(JSON.stringify(credentialData));
credentialData.properties = this.getCredentialProperties(credentialData.name);
return credentialData;
},
credentialsCreated (eventData: ICredentialsCreatedEvent): void {
this.$emit('credentialsCreated', eventData);
this.$showMessage({
title: 'Credentials created',
message: `"${eventData.data.name}" credentials were successfully created!`,
type: 'success',
});
if (eventData.options.closeDialog === true) {
this.closeDialog();
}
},
credentialsUpdated (eventData: ICredentialsCreatedEvent): void {
this.$emit('credentialsUpdated', eventData);
this.$showMessage({
title: 'Credentials updated',
message: `"${eventData.data.name}" credentials were successfully updated!`,
type: 'success',
});
if (eventData.options.closeDialog === true) {
this.closeDialog();
}
},
closeDialog (): void {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
},
},
});
</script>
<style lang="scss">
.credentials-edit-wrapper {
.credential-type-item {
> .el-row {
display: flex;
align-items: center;
}
padding-bottom: 8px;
}
@media (min-width: 1200px){
.title-container {
display: flex;
flex-direction: row;
max-width: 100%;
line-height: 17px;
}
.docs-container {
margin-left: auto;
margin-right: 0;
}
}
@media (max-width: 1199px){
.title-container {
display: flex;
flex-direction: column;
max-width: 100%;
line-height: 17px;
}
.docs-container {
margin-top: 10px;
margin-left: 0;
margin-right: auto;
}
}
.title-left {
flex: 7;
font-size: 16px;
font-weight: bold;
color: #7a7a7a;
vertical-align:middle;
}
.title-right {
vertical-align: middle;
flex: 3;
font-family: "Open Sans";
color: #666666;
font-size: 12px;
font-weight: 510;
letter-spacing: 0;
display: flex;
flex-direction: row;
min-width: 40%;
}
.help-logo {
flex: 1;
}
.doc-link-text {
margin-left: 2px;
float: right;
word-break: break-word;
flex: 9;
}
.doc-hyperlink,
.doc-hyperlink:visited,
.doc-hyperlink:focus,
.doc-hyperlink:active {
text-decoration: none;
color: #FF6150;
}
}
</style>

View file

@ -1,618 +0,0 @@
<template>
<div @keydown.stop class="credentials-input-wrapper">
<el-row class="credential-name-wrapper">
<el-col :span="6" class="headline-regular">
Credentials Name:
<n8n-tooltip class="credentials-info" placement="top" >
<div slot="content" v-html="helpTexts.credentialsName"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="18">
<n8n-input v-model="name" type="text" size="medium"></n8n-input>
</el-col>
</el-row>
<br />
<div class="headline" v-if="credentialProperties.length">
Credential Data:
<n8n-tooltip class="credentials-info" placement="top" >
<div slot="content" v-html="helpTexts.credentialsData"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</div>
<div v-for="parameter in credentialProperties" :key="parameter.name">
<el-row class="parameter-wrapper">
<el-col :span="6" class="parameter-name">
{{parameter.displayName}}:
<n8n-tooltip placement="top" class="parameter-info" v-if="parameter.description" >
<div slot="content" v-html="parameter.description"></div>
<font-awesome-icon icon="question-circle"/>
</n8n-tooltip>
</el-col>
<el-col :span="18">
<parameter-input :parameter="parameter" :value="propertyValue[parameter.name]" :path="parameter.name" :isCredential="true" :displayOptions="true" @valueChanged="valueChanged" inputSize="medium" />
</el-col>
</el-row>
</div>
<el-row v-if="isOAuthType" class="oauth-information">
<el-col :span="6" class="headline">
OAuth
</el-col>
<el-col :span="18">
<span v-if="requiredPropertiesFilled === false">
<n8n-icon-button title="Connect OAuth Credentials" icon="redo" :disabled="true" size="large" />
Enter all required properties
</span>
<span v-else-if="isOAuthConnected === true">
<n8n-icon-button title="Reconnect OAuth Credentials" @click.stop="oAuthCredentialAuthorize()" icon="redo" size="large" />
Connected
</span>
<span v-else>
<span v-if="isGoogleOAuthType">
<img :src="basePath + 'google-signin.png'" class="google-icon clickable" alt="Sign in with Google" @click.stop="oAuthCredentialAuthorize()" />
</span>
<span v-else>
<n8n-icon-button title="Connect OAuth Credentials" @click.stop="oAuthCredentialAuthorize()" icon="sign-in-alt" size="large" />
Not connected
</span>
</span>
<div v-if="credentialProperties.length">
<div class="clickable oauth-callback-headline" :class="{expanded: !isMinimized}" @click="isMinimized=!isMinimized" :title="isMinimized ? 'Click to display Webhook URLs' : 'Click to hide Webhook URLs'">
<font-awesome-icon icon="angle-up" class="minimize-button minimize-icon" />
OAuth Callback URL
</div>
<n8n-tooltip v-if="!isMinimized" class="item" content="Click to copy Callback URL" placement="right">
<div class="callback-url left-ellipsis clickable" @click="copyCallbackUrl">
{{oAuthCallbackUrl}}
</div>
</n8n-tooltip>
</div>
</el-col>
</el-row>
<el-row class="nodes-access-wrapper">
<el-col :span="6" class="headline">
Nodes with access:
<n8n-tooltip class="credentials-info" placement="top" >
<div slot="content" v-html="helpTexts.nodesWithAccess"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="18">
<el-transfer
:titles="['No Access', 'Access ']"
v-model="nodesAccess"
:data="allNodesRequestingAccess">
</el-transfer>
<div v-if="nodesAccess.length === 0" class="no-nodes-access">
<strong>
Important
</strong><br />
Add at least one node which has access to the credentials!
</div>
</el-col>
</el-row>
<div class="action-buttons">
<n8n-button type="success" @click="updateCredentials(true)" label="Save" size="large" v-if="credentialDataDynamic" />
<n8n-button @click="createCredentials(true)" label="Create" size="large" v-else />
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from '@/components/mixins/externalHooks';
import { restApi } from '@/components/mixins/restApi';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import {
ICredentialsDecryptedResponse,
ICredentialsResponse,
IUpdateInformation,
} from '@/Interface';
import {
CredentialInformation,
ICredentialDataDecryptedObject,
ICredentialsDecrypted,
ICredentialType,
ICredentialNodeAccess,
INodeCredentialDescription,
INodeParameters,
INodeProperties,
INodeTypeDescription,
NodeHelpers,
} from 'n8n-workflow';
import ParameterInput from '@/components/ParameterInput.vue';
import mixins from 'vue-typed-mixins';
import { addTargetBlank } from './helpers';
export default mixins(
copyPaste,
externalHooks,
nodeHelpers,
restApi,
showMessage,
).extend({
name: 'CredentialsInput',
props: [
'credentialTypeData', // ICredentialType
'credentialData', // ICredentialsDecryptedResponse
'nodesInit', // {
// type: Array,
// default: () => { [] },
// }
],
components: {
ParameterInput,
},
data () {
return {
basePath: this.$store.getters.getBaseUrl,
isMinimized: true,
helpTexts: {
credentialsData: 'The credentials to set.',
credentialsName: 'A recognizable label for the credentials. Descriptive names work <br />best here, so you can easily select it from a list later.',
nodesWithAccess: 'Nodes with access to these credentials.',
},
credentialDataTemp: null as ICredentialsDecryptedResponse | null,
nodesAccess: [] as string[],
name: '',
propertyValue: {} as ICredentialDataDecryptedObject,
};
},
computed: {
allNodesRequestingAccess (): Array<{key: string, label: string}> {
const returnNodeTypes: string[] = [];
const nodeTypes: INodeTypeDescription[] = this.$store.getters.allNodeTypes;
let nodeType: INodeTypeDescription;
let credentialTypeDescription: INodeCredentialDescription;
// Find the node types which need the credentials
for (nodeType of nodeTypes) {
if (!nodeType.credentials) {
continue;
}
for (credentialTypeDescription of nodeType.credentials) {
if (credentialTypeDescription.name === (this.credentialTypeData as ICredentialType).name && !returnNodeTypes.includes(credentialTypeDescription.name)) {
returnNodeTypes.push(nodeType.name);
break;
}
}
}
// Return the data in the correct format el-transfer expects
return returnNodeTypes.map((nodeTypeName: string) => {
return {
key: nodeTypeName,
label: this.$store.getters.nodeType(nodeTypeName).displayName as string,
};
});
},
credentialProperties (): INodeProperties[] {
return this.credentialTypeData.properties.filter((propertyData: INodeProperties) => {
if (!this.displayCredentialParameter(propertyData)) {
return false;
}
return !this.credentialTypeData.__overwrittenProperties || !this.credentialTypeData.__overwrittenProperties.includes(propertyData.name);
});
},
credentialDataDynamic (): ICredentialsDecryptedResponse | null {
if (this.credentialData) {
return this.credentialData;
}
return this.credentialDataTemp;
},
isGoogleOAuthType (): boolean {
if (this.credentialTypeData.name === 'googleOAuth2Api') {
return true;
}
const types = this.parentTypes(this.credentialTypeData.name);
return types.includes('googleOAuth2Api');
},
isOAuthType (): boolean {
if (['oAuth1Api', 'oAuth2Api'].includes(this.credentialTypeData.name)) {
return true;
}
const types = this.parentTypes(this.credentialTypeData.name);
return types.includes('oAuth1Api') || types.includes('oAuth2Api');
},
isOAuthConnected (): boolean {
if (this.isOAuthType === false) {
return false;
}
return this.credentialDataDynamic !== null && !!this.credentialDataDynamic.data!.oauthTokenData;
},
oAuthCallbackUrl (): string {
const types = this.parentTypes(this.credentialTypeData.name);
const oauthType = (this.credentialTypeData.name === 'oAuth2Api' || types.includes('oAuth2Api')) ? 'oauth2' : 'oauth1';
return this.$store.getters.oauthCallbackUrls[oauthType];
},
requiredPropertiesFilled (): boolean {
for (const property of this.credentialProperties) {
if (property.required !== true) {
continue;
}
if (!this.propertyValue[property.name]) {
return false;
}
}
return true;
},
},
methods: {
addTargetBlank,
copyCallbackUrl (): void {
this.copyToClipboard(this.oAuthCallbackUrl);
this.$showMessage({
title: 'Copied',
message: `Callback URL was successfully copied!`,
type: 'success',
});
},
parentTypes (name: string): string[] {
const credentialType = this.$store.getters.credentialType(name);
if (credentialType === undefined || credentialType.extends === undefined) {
return [];
}
const types: string[] = [];
for (const typeName of credentialType.extends) {
types.push(typeName);
types.push.apply(types, this.parentTypes(typeName));
}
return types;
},
valueChanged (parameterData: IUpdateInformation) {
const name = parameterData.name.split('.').pop() as string;
// For a currently for me unknown reason can In not simply just
// set the value and it has to be this way.
const tempValue = JSON.parse(JSON.stringify(this.propertyValue));
tempValue[name] = parameterData.value;
Vue.set(this, 'propertyValue', tempValue);
},
displayCredentialParameter (parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') {
return false;
}
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
return this.displayParameter(this.propertyValue as INodeParameters, parameter, '');
},
async createCredentials (closeDialog: boolean): Promise<ICredentialsResponse | null> {
const nodesAccess = this.nodesAccess.map((nodeType) => {
return {
nodeType,
};
});
const newCredentials = {
name: this.name,
type: (this.credentialTypeData as ICredentialType).name,
nodesAccess,
// Save only the none default data
data: NodeHelpers.getNodeParameters(this.credentialTypeData.properties as INodeProperties[], this.propertyValue as INodeParameters, false, false),
} as ICredentialsDecrypted;
let result;
try {
result = await this.restApi().createNewCredentials(newCredentials);
} catch (error) {
this.$showError(error, 'Problem Creating Credentials', 'There was a problem creating the credentials:');
return null;
}
// Add also to local store
this.$store.commit('addCredentials', result);
this.$emit('credentialsCreated', {data: result, options: { closeDialog }});
this.$externalHooks().run('credentials.create', { credentialTypeData: this.credentialTypeData });
return result;
},
async oAuthCredentialAuthorize () {
let url;
let credentialData = this.credentialDataDynamic;
let newCredentials = false;
if (!credentialData) {
// Credentials did not get created yet. So create first before
// doing oauth authorize
credentialData = await this.createCredentials(false) as ICredentialsDecryptedResponse;
newCredentials = true;
if (credentialData === null) {
return;
}
// Set the internal data directly so that even if it fails it displays a "Save" instead
// of the "Create" button. If that would not be done, people could not retry after a
// connect issue as it woult try to create credentials again which would fail as they
// exist already.
Vue.set(this, 'credentialDataTemp', credentialData);
} else {
// Exists already but got maybe changed. So save first
credentialData = await this.updateCredentials(false) as ICredentialsDecryptedResponse;
if (credentialData === null) {
return;
}
}
const types = this.parentTypes(this.credentialTypeData.name);
try {
if (this.credentialTypeData.name === 'oAuth2Api' || types.includes('oAuth2Api')) {
url = await this.restApi().oAuth2CredentialAuthorize(credentialData as ICredentialsResponse) as string;
} else if (this.credentialTypeData.name === 'oAuth1Api' || types.includes('oAuth1Api')) {
url = await this.restApi().oAuth1CredentialAuthorize(credentialData as ICredentialsResponse) as string;
}
} catch (error) {
this.$showError(error, 'OAuth Authorization Error', 'Error generating authorization URL:');
return;
}
const params = `scrollbars=no,resizable=yes,status=no,titlebar=noe,location=no,toolbar=no,menubar=no,width=500,height=700`;
const oauthPopup = window.open(url, 'OAuth2 Authorization', params);
const receiveMessage = (event: MessageEvent) => {
// // TODO: Add check that it came from n8n
// if (event.origin !== 'http://example.org:8080') {
// return;
// }
if (event.data === 'success') {
// Set some kind of data that status changes.
// As data does not get displayed directly it does not matter what data.
if (this.credentialData === null) {
// Are new credentials so did not get send via "credentialData"
Vue.set(this, 'credentialDataTemp', credentialData);
Vue.set(this.credentialDataTemp!.data!, 'oauthTokenData', {});
} else {
// Credentials did already exist so can be set directly
Vue.set(this.credentialData.data, 'oauthTokenData', {});
}
// Save that OAuth got authorized locally
this.$store.commit('updateCredentials', this.credentialDataDynamic);
// Close the window
if (oauthPopup) {
oauthPopup.close();
}
if (newCredentials === true) {
this.$emit('credentialsCreated', {data: credentialData, options: { closeDialog: false }});
}
this.$showMessage({
title: 'Connected',
message: 'Connected successfully!',
type: 'success',
});
// Make sure that the event gets removed again
window.removeEventListener('message', receiveMessage, false);
}
};
window.addEventListener('message', receiveMessage, false);
},
async updateCredentials (closeDialog: boolean): Promise<ICredentialsResponse | null> {
const nodesAccess: ICredentialNodeAccess[] = [];
const addedNodeTypes: string[] = [];
// Add Node-type which already had access to keep the original added date
let nodeAccessData: ICredentialNodeAccess;
for (nodeAccessData of (this.credentialDataDynamic as ICredentialsDecryptedResponse).nodesAccess) {
if (this.nodesAccess.includes((nodeAccessData.nodeType))) {
nodesAccess.push(nodeAccessData);
addedNodeTypes.push(nodeAccessData.nodeType);
}
}
// Add Node-type which did not have access before
for (const nodeType of this.nodesAccess) {
if (!addedNodeTypes.includes(nodeType)) {
nodesAccess.push({
nodeType,
});
}
}
const newCredentials = {
name: this.name,
type: (this.credentialTypeData as ICredentialType).name,
nodesAccess,
// Save only the none default data
data: NodeHelpers.getNodeParameters(this.credentialTypeData.properties as INodeProperties[], this.propertyValue as INodeParameters, false, false),
} as ICredentialsDecrypted;
let result;
try {
result = await this.restApi().updateCredentials((this.credentialDataDynamic as ICredentialsDecryptedResponse).id as string, newCredentials);
} catch (error) {
this.$showError(error, 'Problem Updating Credentials', 'There was a problem updating the credentials:');
return null;
}
// Update also in local store
this.$store.commit('updateCredentials', result);
// Now that the credentials changed check if any nodes use credentials
// which have now a different name
this.updateNodesCredentialsIssues();
this.$emit('credentialsUpdated', {data: result, options: { closeDialog }});
return result;
},
init () {
if (this.credentialData) {
// Initialize with the given data
this.name = (this.credentialData as ICredentialsDecryptedResponse).name;
this.propertyValue = (this.credentialData as ICredentialsDecryptedResponse).data as ICredentialDataDecryptedObject;
const nodesAccess = (this.credentialData as ICredentialsDecryptedResponse).nodesAccess.map((nodeAccess) => {
return nodeAccess.nodeType;
});
Vue.set(this, 'nodesAccess', nodesAccess);
} else {
// No data supplied so init empty
this.name = '';
this.propertyValue = {} as ICredentialDataDecryptedObject;
const nodesAccess = [] as string[];
nodesAccess.push.apply(nodesAccess, this.nodesInit);
Vue.set(this, 'nodesAccess', nodesAccess);
}
// Set default values
for (const property of (this.credentialTypeData as ICredentialType).properties) {
if (!this.propertyValue.hasOwnProperty(property.name)) {
this.propertyValue[property.name] = property.default as CredentialInformation;
}
}
},
},
watch: {
credentialData () {
this.init();
},
credentialTypeData () {
this.init();
},
},
mounted () {
this.init();
},
});
</script>
<style lang="scss">
.credentials-input-wrapper {
.credential-name-wrapper {
display: flex;
align-items: center;
}
.action-buttons {
margin-top: 2em;
text-align: right;
}
.headline {
font-weight: 600;
color: $--color-primary;
margin-bottom: 1em;
line-height: 1.5;
}
.headline-regular {
line-height: 1.5;
}
.nodes-access-wrapper {
margin-top: 1em;
}
.no-nodes-access {
margin: 1em 0;
color: $--color-primary;
line-height: 1.75em;
}
.oauth-information {
line-height: 2.5em;
margin: 2em 0;
.google-icon {
width: 191px;
}
}
.parameter-wrapper {
display: flex;
align-items: center;
margin: 8px 0;
.parameter-name {
position: relative;
&:hover {
.parameter-info {
display: inline;
}
}
.parameter-info {
display: none;
}
}
}
.credentials-info {
display: none;
}
.callback-url {
position: relative;
top: 0;
width: 100%;
font-size: 0.9em;
white-space: normal;
overflow: visible;
text-overflow: initial;
color: #404040;
text-align: left;
direction: ltr;
word-break: break-all;
}
.headline:hover,
.headline-regular:hover {
.credentials-info {
display: inline;
}
}
.expanded .minimize-button {
-webkit-transform: rotate(180deg);
-moz-transform: rotate(180deg);
-o-transform: rotate(180deg);
transform: rotate(180deg);
}
.oauth-callback-headline {
padding-top: 1em;
font-weight: 500;
}
}
</style>

View file

@ -1,7 +1,5 @@
<template>
<div v-if="dialogVisible">
<credentials-edit :dialogVisible="credentialEditDialogVisible" @closeDialog="closeCredentialEditDialog" @credentialsUpdated="reloadCredentialList" @credentialsCreated="reloadCredentialList" :setCredentialType="editCredentials && editCredentials.type" :editCredentials="editCredentials"></credentials-edit>
<el-dialog :visible="dialogVisible" append-to-body width="80%" title="Credentials" :before-close="closeDialog">
<div class="text-very-light">
Your saved credentials:
@ -17,13 +15,9 @@
/>
</div>
<el-table :data="credentials" :default-sort = "{prop: 'name', order: 'ascending'}" stripe @row-click="editCredential" max-height="450" v-loading="isDataLoading">
<el-table :data="credentialsToDisplay" :default-sort = "{prop: 'name', order: 'ascending'}" stripe max-height="450" @row-click="editCredential">
<el-table-column property="name" label="Name" class-name="clickable" sortable></el-table-column>
<el-table-column property="type" label="Type" class-name="clickable" sortable>
<template slot-scope="scope">
{{credentialTypeDisplayNames[scope.row.type]}}
</template>
</el-table-column>
<el-table-column property="type" label="Type" class-name="clickable" sortable></el-table-column>
<el-table-column property="createdAt" label="Created" class-name="clickable" sortable></el-table-column>
<el-table-column property="updatedAt" label="Updated" class-name="clickable" sortable></el-table-column>
<el-table-column
@ -43,136 +37,89 @@
<script lang="ts">
import { externalHooks } from '@/components/mixins/externalHooks';
import { restApi } from '@/components/mixins/restApi';
import { ICredentialsResponse } from '@/Interface';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { mapGetters } from "vuex";
import mixins from 'vue-typed-mixins';
import { convertToDisplayDate } from './helpers';
export default mixins(
externalHooks,
genericHelpers,
nodeHelpers,
restApi,
showMessage,
).extend({
name: 'CredentialsList',
props: [
'dialogVisible',
],
components: {
CredentialsEdit,
},
data () {
return {
credentialEditDialogVisible: false,
credentialTypeDisplayNames: {} as { [key: string]: string; },
credentials: [] as ICredentialsResponse[],
displayAddCredentials: false,
editCredentials: null as ICredentialsResponse | null,
isDataLoading: false,
};
computed: {
...mapGetters('credentials', ['allCredentials']),
credentialsToDisplay() {
return this.allCredentials.reduce((accu: ICredentialsResponse[], cred: ICredentialsResponse) => {
const type = this.$store.getters['credentials/getCredentialTypeByName'](cred.type);
if (type) {
accu.push({
...cred,
type: type.displayName,
createdAt: convertToDisplayDate(cred.createdAt as number),
updatedAt: convertToDisplayDate(cred.updatedAt as number),
});
}
return accu;
}, []);
},
},
watch: {
dialogVisible (newValue) {
if (newValue) {
this.loadCredentials();
this.loadCredentialTypes();
}
this.$externalHooks().run('credentialsList.dialogVisibleChanged', { dialogVisible: newValue });
},
},
methods: {
closeCredentialEditDialog () {
this.credentialEditDialogVisible = false;
},
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
createCredential () {
this.editCredentials = null;
this.credentialEditDialogVisible = true;
this.$store.dispatch('ui/openCredentialsSelectModal');
},
editCredential (credential: ICredentialsResponse) {
const editCredentials = {
id: credential.id,
name: credential.name,
type: credential.type,
} as ICredentialsResponse;
this.editCredentials = editCredentials;
this.credentialEditDialogVisible = true;
},
reloadCredentialList () {
this.loadCredentials();
},
loadCredentialTypes () {
if (Object.keys(this.credentialTypeDisplayNames).length !== 0) {
// Data is already loaded
return;
}
if (this.$store.getters.allCredentialTypes === null) {
// Data is not ready yet to be loaded
return;
}
for (const credentialType of this.$store.getters.allCredentialTypes) {
this.credentialTypeDisplayNames[credentialType.name] = credentialType.displayName;
}
},
loadCredentials () {
this.isDataLoading = true;
try {
this.credentials = JSON.parse(JSON.stringify(this.$store.getters.allCredentials));
} catch (error) {
this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:');
this.isDataLoading = false;
return;
}
this.credentials.forEach((credentialData: ICredentialsResponse) => {
credentialData.createdAt = this.convertToDisplayDate(credentialData.createdAt as number);
credentialData.updatedAt = this.convertToDisplayDate(credentialData.updatedAt as number);
});
this.isDataLoading = false;
this.$store.dispatch('ui/openExisitngCredential', { id: credential.id});
},
async deleteCredential (credential: ICredentialsResponse) {
const deleteConfirmed = await this.confirmMessage(`Are you sure you want to delete "${credential.name}" credentials?`, 'Delete Credentials?', 'warning', 'Yes, delete!');
const deleteConfirmed = await this.confirmMessage(`Are you sure you want to delete "${credential.name}" credentials?`, 'Delete Credentials?', null, 'Yes, delete!');
if (deleteConfirmed === false) {
return;
}
try {
await this.restApi().deleteCredentials(credential.id!);
await this.$store.dispatch('credentials/deleteCredential', {id: credential.id});
} catch (error) {
this.$showError(error, 'Problem deleting credentials', 'There was a problem deleting the credentials:');
return;
}
// Remove also from local store
this.$store.commit('removeCredentials', credential);
// Now that the credentials got removed check if any nodes used them
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Credentials deleted',
message: `The credential "${credential.name}" got deleted!`,
message: `The credential "${credential.name}" was deleted!`,
type: 'success',
});
// Refresh list
this.loadCredentials();
},
},
});

View file

@ -0,0 +1,109 @@
<template>
<Modal
:name="modalName"
:eventBus="modalBus"
width="50%"
:center="true"
maxWidth="460px"
>
<template slot="header">
<h2 :class="$style.title">Add new credential</h2>
</template>
<template slot="content">
<div :class="$style.container">
<div :class="$style.subtitle">Select an app or service to connect to</div>
<n8n-select
filterable
defaultFirstOption
placeholder="Search for app..."
size="xlarge"
ref="select"
:value="selected"
@change="onSelect"
>
<font-awesome-icon icon="search" slot="prefix" />
<n8n-option
v-for="credential in allCredentialTypes"
:value="credential.name"
:key="credential.name"
:label="credential.displayName"
filterable
/>
</n8n-select>
</div>
</template>
<template slot="footer">
<div :class="$style.footer">
<n8n-button
label="Continue"
float="right"
size="large"
:disabled="!selected"
@click="openCredentialType"
/>
</div>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters } from "vuex";
import Modal from './Modal.vue';
export default Vue.extend({
name: 'CredentialsSelectModal',
components: {
Modal,
},
mounted() {
setTimeout(() => {
const element = this.$refs.select as HTMLSelectElement;
if (element) {
element.focus();
}
}, 0);
},
data() {
return {
modalBus: new Vue(),
selected: '',
};
},
computed: {
...mapGetters('credentials', ['allCredentialTypes']),
},
props: {
modalName: {
type: String,
},
},
methods: {
onSelect(type: string) {
this.selected = type;
},
openCredentialType () {
this.modalBus.$emit('close');
this.$store.dispatch('ui/openNewCredential', { type: this.selected });
},
},
});
</script>
<style module lang="scss">
.container {
margin-bottom: var(--spacing-l);
}
.title {
font-size: var(--font-size-xl);
line-height: var(--font-line-height-regular);
}
.subtitle {
margin-bottom: var(--spacing-s);
font-size: var(--font-size-m);
line-height: var(--font-line-height-xloose);
}
</style>

View file

@ -1,39 +1,41 @@
<template>
<transition name="el-fade-in" @after-enter="showDocumentHelp = true">
<div class="data-display-wrapper close-on-click" v-show="node" @click="close">
<div class="data-display" >
<NodeSettings @valueChanged="valueChanged" />
<RunData />
<div class="close-button clickable close-on-click" title="Close">
<i class="el-icon-close close-on-click"></i>
</div>
<transition name="fade">
<div v-if="showDocumentHelp && nodeType" class="doc-help-wrapper">
<svg id="help-logo" v-if="showDocumentHelp && nodeType" :href="documentationUrl" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Node Documentation</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
<g transform="translate(10.000000, 11.000000)">
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
</g>
<rect x="0" y="0" width="18" height="18"></rect>
</g>
<el-dialog
:visible="!!node"
:before-close="close"
:custom-class="`classic data-display-wrapper`"
width="80%"
append-to-body
@opened="showDocumentHelp = true"
>
<div class="data-display" >
<NodeSettings @valueChanged="valueChanged" />
<RunData />
</div>
<transition name="fade">
<div v-if="nodeType && showDocumentHelp" class="doc-help-wrapper">
<svg id="help-logo" :href="documentationUrl" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Node Documentation</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
<g transform="translate(10.000000, 11.000000)">
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
</g>
<rect x="0" y="0" width="18" height="18"></rect>
</g>
</g>
</svg>
</g>
</g>
</svg>
<div v-if="showDocumentHelp && nodeType" class="text">
Need help? <a id="doc-hyperlink" v-if="showDocumentHelp && nodeType" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open {{nodeType.displayName}} documentation</a>
</div>
<div class="text">
Need help? <a id="doc-hyperlink" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open {{nodeType.displayName}} documentation</a>
</div>
</transition>
</div>
</div>
</transition>
</transition>
</el-dialog>
</template>
<script lang="ts">
@ -102,13 +104,10 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
close (e: MouseEvent) {
// @ts-ignore
if (e.target.className && e.target.className.includes && e.target.className.includes('close-on-click')) {
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.showDocumentHelp = false;
this.$store.commit('setActiveNode', null);
}
close () {
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.showDocumentHelp = false;
this.$store.commit('setActiveNode', null);
},
onDocumentationUrlClick () {
this.$externalHooks().run('dataDisplay.onDocumentationUrlClick', { nodeType: this.nodeType, documentationUrl: this.documentationUrl });
@ -119,105 +118,70 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
</script>
<style lang="scss">
.data-display-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
background-color: #9d8d9dd8;
height: 85%;
.close-button {
position: absolute;
top: 0;
right: -50px;
color: #fff;
background-color: $--custom-header-background;
border-radius: 0 18px 18px 0;
z-index: 110;
font-size: 1.7em;
text-align: center;
line-height: 50px;
height: 50px;
width: 50px;
.close-on-click {
color: #fff;
font-weight: 400;
}
.close-on-click:hover {
transform: scale(1.2);
}
.el-dialog__header {
padding: 0 !important;
}
.data-display {
position: relative;
width: 80%;
height: 80%;
margin: 3em auto;
background-color: #fff;
border-radius: 2px;
@media (max-height: 720px) {
margin: 1em auto;
height: 95%;
}
.fade-enter-active, .fade-enter-to, .fade-leave-active {
transition: all .75s ease;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.doc-help-wrapper {
transition-delay: 2s;
background-color: #fff;
margin-top: 1%;
box-sizing: border-box;
border: 1px solid #DCDFE6;
border-radius: 4px;
background-color: #FFFFFF;
box-shadow: 0 2px 7px 0 rgba(0,0,0,0.15);
min-width: 319px;
height: 40px;
float: right;
padding: 5px;
display: flex;
flex-direction: row;
padding-top: 10px;
padding-right: 12px;
#help-logo {
flex: 1;
}
.text {
margin-left: 5px;
flex: 9;
font-family: "Open Sans";
color: #666666;
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
line-height: 17px;
white-space: nowrap;
}
#doc-hyperlink, #doc-hyperlink:visited, #doc-hyperlink:focus, #doc-hyperlink:active {
text-decoration: none;
color: #FF6150;
}
}
.el-dialog__body {
padding: 0 !important;
height: 100%;
min-height: 400px;
overflow: hidden;
border-radius: 8px;
}
}
.data-display {
background-color: #fff;
border-radius: 8px;
display: flex;
height: 100%;
}
.doc-help-wrapper {
position: absolute;
right: 0;
transition-delay: 2s;
background-color: #fff;
margin-top: 1%;
box-sizing: border-box;
border: 1px solid #DCDFE6;
border-radius: 4px;
background-color: #FFFFFF;
box-shadow: 0 2px 7px 0 rgba(0,0,0,0.15);
min-width: 319px;
height: 40px;
float: right;
padding: 5px;
display: flex;
flex-direction: row;
padding-top: 10px;
padding-right: 12px;
#help-logo {
flex: 1;
}
.text {
margin-left: 5px;
flex: 9;
font-family: "Open Sans";
font-size: 12px;
font-weight: 600;
line-height: 17px;
white-space: nowrap;
}
}
.fade-enter-active, .fade-enter-to, .fade-leave-active {
transition: all .75s ease;
opacity: 1;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
</style>

View file

@ -3,30 +3,34 @@
:name="modalName"
:eventBus="modalBus"
@enter="save"
size="sm"
title="Duplicate Workflow"
:center="true"
minWidth="420px"
maxWidth="420px"
>
<template v-slot:content>
<el-row>
<n8n-input
v-model="name"
ref="nameInput"
placeholder="Enter workflow name"
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
/>
</el-row>
<el-row>
<TagsDropdown
:createEnabled="true"
:currentTagIds="currentTagIds"
:eventBus="dropdownBus"
@blur="onTagsBlur"
@esc="onTagsEsc"
@update="onTagsUpdate"
placeholder="Choose or create a tag"
ref="dropdown"
/>
</el-row>
<div :class="$style.content">
<el-row>
<n8n-input
v-model="name"
ref="nameInput"
placeholder="Enter workflow name"
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
/>
</el-row>
<el-row>
<TagsDropdown
:createEnabled="true"
:currentTagIds="currentTagIds"
:eventBus="dropdownBus"
@blur="onTagsBlur"
@esc="onTagsEsc"
@update="onTagsUpdate"
placeholder="Choose or create a tag"
ref="dropdown"
/>
</el-row>
</div>
</template>
<template v-slot:footer="{ close }">
<div :class="$style.footer">
@ -127,6 +131,12 @@ export default mixins(showMessage, workflowHelpers).extend({
</script>
<style lang="scss" module>
.content {
> div {
margin-bottom: 15px;
}
}
.footer {
> * {
margin-left: var(--spacing-3xs);

View file

@ -43,10 +43,10 @@
<el-table-column label="" width="30">
<!-- eslint-disable-next-line vue/no-unused-vars -->
<template slot="header" slot-scope="scope">
<el-checkbox :indeterminate="isIndeterminate" v-model="checkAll" @change="handleCheckAllChange">Check all</el-checkbox>
<el-checkbox :indeterminate="isIndeterminate" v-model="checkAll" @change="handleCheckAllChange" label=" "></el-checkbox>
</template>
<template slot-scope="scope">
<el-checkbox v-if="scope.row.stoppedAt !== undefined && scope.row.id" :value="selectedItems[scope.row.id.toString()] || checkAll" @change="handleCheckboxChanged(scope.row.id)" >Check all</el-checkbox>
<el-checkbox v-if="scope.row.stoppedAt !== undefined && scope.row.id" :value="selectedItems[scope.row.id.toString()] || checkAll" @change="handleCheckboxChanged(scope.row.id)" label=" "></el-checkbox>
</template>
</el-table-column>
<el-table-column property="startedAt" label="Started At / ID" width="205">
@ -173,6 +173,10 @@ import {
IWorkflowShortResponse,
} from '@/Interface';
import {
convertToDisplayDate,
} from './helpers';
import {
IDataObject,
} from 'n8n-workflow';
@ -319,6 +323,7 @@ export default mixins(
}
return false;
},
convertToDisplayDate,
displayExecution (execution: IExecutionShortResponse) {
this.$router.push({
name: 'ExecutionById',
@ -380,7 +385,7 @@ export default mixins(
this.$showMessage({
title: 'Execution deleted',
message: 'The executions got deleted!',
message: 'The executions were deleted!',
type: 'success',
});

View file

@ -1,6 +1,6 @@
<template>
<div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" custom-class="expression-dialog" append-to-body width="80%" title="Edit Expression" :before-close="closeDialog">
<el-dialog :visible="dialogVisible" custom-class="expression-dialog classic" append-to-body width="80%" title="Edit Expression" :before-close="closeDialog">
<el-row>
<el-col :span="8">
<div class="header-side-menu">
@ -145,11 +145,14 @@ export default mixins(
.right-side {
background-color: #f9f9f9;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
}
.header-side-menu {
padding: 1em 0 0.5em 1.8em;
border-top-left-radius: 8px;
background-color: $--custom-window-sidebar-top;
color: #555;

View file

@ -5,7 +5,8 @@
</div>
<div v-for="property in getProperties" :key="property.name" class="fixed-collection-parameter-property">
<div class="parameter-name" :title="property.displayName">{{property.displayName}}:</div>
<div v-if="property.displayName === '' || parameter.options.length === 1"></div>
<div v-else class="parameter-name" :title="property.displayName">{{property.displayName}}:</div>
<div v-if="multipleValues === true">
<div v-for="(value, index) in values[property.name]" :key="property.name + index" class="parameter-item">

View file

@ -0,0 +1,128 @@
<template>
<div :class="$style.container">
<div
:class="$style.headline"
@keydown.stop
@click="enableNameEdit"
v-click-outside="disableNameEdit"
>
<div v-if="!isNameEdit">
<span>{{ name }}</span>
<i><font-awesome-icon icon="pen" /></i>
</div>
<div v-else :class="$style.nameInput">
<n8n-input
:value="name"
size="xlarge"
ref="nameInput"
@input="onNameEdit"
@change="disableNameEdit"
:maxlength="64"
/>
</div>
</div>
<div :class="$style.subtitle" v-if="!isNameEdit">{{ subtitle }}</div>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { showMessage } from './mixins/showMessage';
export default mixins(showMessage).extend({
name: 'InlineNameEdit',
props: {
name: {
type: String,
},
subtitle: {
type: String,
},
type: {
type: String,
},
},
data() {
return {
isNameEdit: false,
};
},
methods: {
onNameEdit(value: string) {
this.$emit('input', value);
},
enableNameEdit() {
this.isNameEdit = true;
setTimeout(() => {
const input = this.$refs.nameInput as HTMLInputElement;
if (input) {
input.focus();
}
}, 0);
},
disableNameEdit() {
if (!this.name) {
this.$emit('input', `Untitled ${this.type}`);
this.$showToast({
title: 'Error',
message: `${this.type} name cannot be empty`,
type: 'warning',
});
}
this.isNameEdit = false;
},
},
});
</script>
<style module lang="scss">
.container {
min-height: 36px;
}
.headline {
font-size: var(--font-size-m);
line-height: 1.4;
margin-bottom: var(--spacing-5xs);
display: inline-block;
cursor: pointer;
padding: 0 var(--spacing-4xs);
border-radius: var(--border-radius-base);
position: relative;
min-height: 22px;
max-height: 22px;
font-weight: 400;
i {
display: var(--headline-icon-display, none);
font-size: 0.75em;
margin-left: 8px;
color: var(--color-text-base);
}
&:hover {
background-color: var(--color-background-base);
--headline-icon-display: inline-flex;
}
}
.nameInput {
z-index: 1;
position: absolute;
top: -13px;
left: -9px;
width: 400px;
}
.subtitle {
font-size: var(--font-size-2xs);
color: var(--color-text-light);
margin-left: 4px;
font-weight: 400;
}
</style>

View file

@ -46,24 +46,6 @@ export default mixins(
</script>
<style lang="scss">
.el-menu--horizontal>.el-menu-item,
.el-menu--horizontal>.el-submenu .el-submenu__title,
.el-menu-item {
height: 65px;
line-height: 65px;
}
.el-submenu .el-submenu__title,
.el-menu--horizontal>.el-menu-item,
.el-menu.el-menu--horizontal {
border: none !important;
}
.el-menu--popup-bottom-start {
margin-top: 0px;
border-top: 1px solid #464646;
border-radius: 0 0 2px 2px;
}
.main-header {
position: fixed;
top: 0;

View file

@ -65,7 +65,11 @@
<span>Active:</span>
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" :disabled="!currentWorkflowId"/>
</span>
<SaveWorkflowButton />
<SaveButton
:saved="!this.isDirty && !this.isNewWorkflow"
:disabled="isWorkflowSaving"
@click="saveCurrentWorkflow"
/>
</template>
</PushConnectionTracker>
</div>
@ -82,7 +86,7 @@ import TagsContainer from "@/components/TagsContainer.vue";
import PushConnectionTracker from "@/components/PushConnectionTracker.vue";
import WorkflowActivator from "@/components/WorkflowActivator.vue";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
import SaveWorkflowButton from "@/components/SaveWorkflowButton.vue";
import SaveButton from "@/components/SaveButton.vue";
import TagsDropdown from "@/components/TagsDropdown.vue";
import InlineTextEdit from "@/components/InlineTextEdit.vue";
import BreakpointsObserver from "@/components/BreakpointsObserver.vue";
@ -103,7 +107,7 @@ export default mixins(workflowHelpers).extend({
PushConnectionTracker,
WorkflowNameShort,
WorkflowActivator,
SaveWorkflowButton,
SaveButton,
TagsDropdown,
InlineTextEdit,
BreakpointsObserver,
@ -125,6 +129,9 @@ export default mixins(workflowHelpers).extend({
isDirty: "getStateIsDirty",
currentWorkflowTagIds: "workflowTags",
}),
isNewWorkflow(): boolean {
return !this.$route.params.name;
},
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},

View file

@ -3,113 +3,111 @@
<about :dialogVisible="aboutDialogVisible" @closeDialog="closeAboutDialog"></about>
<executions-list :dialogVisible="executionsListDialogVisible" @closeDialog="closeExecutionsListOpenDialog"></executions-list>
<credentials-list :dialogVisible="credentialOpenDialogVisible" @closeDialog="closeCredentialOpenDialog"></credentials-list>
<credentials-edit :dialogVisible="credentialNewDialogVisible" @closeDialog="closeCredentialNewDialog"></credentials-edit>
<workflow-settings :dialogVisible="workflowSettingsDialogVisible" @closeDialog="closeWorkflowSettingsDialog"></workflow-settings>
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
<div class="side-menu-wrapper" :class="{expanded: !isCollapsed}">
<div id="collapse-change-button" class="clickable" @click="toggleCollapse">
<font-awesome-icon icon="angle-right" class="icon" />
</div>
<el-menu default-active="workflow" @select="handleSelect" :collapse="isCollapsed">
<n8n-menu default-active="workflow" @select="handleSelect" :collapse="isCollapsed">
<el-menu-item index="logo" class="logo-item">
<n8n-menu-item index="logo" class="logo-item">
<a href="https://n8n.io" target="_blank" class="logo">
<img :src="basePath + 'n8n-icon-small.png'" class="icon" alt="n8n.io"/>
<span class="logo-text" slot="title">n8n.io</span>
</a>
</el-menu-item>
</n8n-menu-item>
<MenuItemsIterator :items="sidebarMenuTopItems" :root="true"/>
<el-submenu index="workflow" title="Workflow">
<el-submenu index="workflow" title="Workflow" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="network-wired"/>&nbsp;
<span slot="title" class="item-title-root">Workflows</span>
</template>
<el-menu-item index="workflow-new">
<n8n-menu-item index="workflow-new">
<template slot="title">
<font-awesome-icon icon="file"/>&nbsp;
<span slot="title" class="item-title">New</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-open">
</n8n-menu-item>
<n8n-menu-item index="workflow-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>&nbsp;
<span slot="title" class="item-title">Open</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-save">
</n8n-menu-item>
<n8n-menu-item index="workflow-save">
<template slot="title">
<font-awesome-icon icon="save"/>
<span slot="title" class="item-title">Save</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
</n8n-menu-item>
<n8n-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="copy"/>
<span slot="title" class="item-title">Duplicate</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-delete" :disabled="!currentWorkflow">
</n8n-menu-item>
<n8n-menu-item index="workflow-delete" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="trash"/>
<span slot="title" class="item-title">Delete</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-download">
</n8n-menu-item>
<n8n-menu-item index="workflow-download">
<template slot="title">
<font-awesome-icon icon="file-download"/>
<span slot="title" class="item-title">Download</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-import-url">
</n8n-menu-item>
<n8n-menu-item index="workflow-import-url">
<template slot="title">
<font-awesome-icon icon="cloud"/>
<span slot="title" class="item-title">Import from URL</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-import-file">
</n8n-menu-item>
<n8n-menu-item index="workflow-import-file">
<template slot="title">
<font-awesome-icon icon="hdd"/>
<span slot="title" class="item-title">Import from File</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-settings" :disabled="!currentWorkflow">
</n8n-menu-item>
<n8n-menu-item index="workflow-settings" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="cog"/>
<span slot="title" class="item-title">Settings</span>
</template>
</el-menu-item>
</n8n-menu-item>
</el-submenu>
<el-submenu index="credentials" title="Credentials">
<el-submenu index="credentials" title="Credentials" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="key"/>&nbsp;
<span slot="title" class="item-title-root">Credentials</span>
</template>
<el-menu-item index="credentials-new">
<n8n-menu-item index="credentials-new">
<template slot="title">
<font-awesome-icon icon="file"/>
<span slot="title" class="item-title">New</span>
</template>
</el-menu-item>
<el-menu-item index="credentials-open">
</n8n-menu-item>
<n8n-menu-item index="credentials-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>
<span slot="title" class="item-title">Open</span>
</template>
</el-menu-item>
</n8n-menu-item>
</el-submenu>
<el-menu-item index="executions">
<n8n-menu-item index="executions">
<font-awesome-icon icon="tasks"/>&nbsp;
<span slot="title" class="item-title-root">Executions</span>
</el-menu-item>
</n8n-menu-item>
<el-submenu index="help" class="help-menu" title="Help">
<el-submenu index="help" class="help-menu" title="Help" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="question"/>&nbsp;
<span slot="title" class="item-title-root">Help</span>
@ -117,25 +115,25 @@
<MenuItemsIterator :items="helpMenuItems" />
<el-menu-item index="help-about">
<n8n-menu-item index="help-about">
<template slot="title">
<font-awesome-icon class="about-icon" icon="info"/>
<span slot="title" class="item-title">About n8n</span>
</template>
</el-menu-item>
</n8n-menu-item>
</el-submenu>
<MenuItemsIterator :items="sidebarMenuBottomItems" :root="true"/>
<div class="footer-menu-items">
<el-menu-item index="updates" class="updates" v-if="hasVersionUpdates" @click="openUpdatesPanel">
<n8n-menu-item index="updates" class="updates" v-if="hasVersionUpdates" @click="openUpdatesPanel">
<div class="gift-container">
<GiftNotificationIcon />
</div>
<span slot="title" class="item-title-root">{{nextVersions.length > 99 ? '99+' : nextVersions.length}} update{{nextVersions.length > 1 ? 's' : ''}} available</span>
</el-menu-item>
</n8n-menu-item>
</div>
</el-menu>
</n8n-menu>
</div>
</div>
@ -153,7 +151,6 @@ import {
} from '../Interface';
import About from '@/components/About.vue';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import CredentialsList from '@/components/CredentialsList.vue';
import ExecutionsList from '@/components/ExecutionsList.vue';
import GiftNotificationIcon from './GiftNotificationIcon.vue';
@ -217,7 +214,6 @@ export default mixins(
name: 'MainHeader',
components: {
About,
CredentialsEdit,
CredentialsList,
ExecutionsList,
GiftNotificationIcon,
@ -229,11 +225,9 @@ export default mixins(
aboutDialogVisible: false,
// @ts-ignore
basePath: this.$store.getters.getBaseUrl,
credentialNewDialogVisible: false,
credentialOpenDialogVisible: false,
executionsListDialogVisible: false,
stopExecutionInProgress: false,
workflowSettingsDialogVisible: false,
helpMenuItems,
};
},
@ -309,18 +303,12 @@ export default mixins(
closeAboutDialog () {
this.aboutDialogVisible = false;
},
closeWorkflowSettingsDialog () {
this.workflowSettingsDialogVisible = false;
},
closeExecutionsListOpenDialog () {
this.executionsListDialogVisible = false;
},
closeCredentialOpenDialog () {
this.credentialOpenDialogVisible = false;
},
closeCredentialNewDialog () {
this.credentialNewDialogVisible = false;
},
openTagManager() {
this.$store.dispatch('ui/openTagsManagerModal');
},
@ -414,8 +402,8 @@ export default mixins(
// Reset tab title since workflow is deleted.
this.$titleReset();
this.$showMessage({
title: 'Workflow got deleted',
message: `The workflow "${this.workflowName}" got deleted!`,
title: 'Workflow was deleted',
message: `The workflow "${this.workflowName}" was deleted!`,
type: 'success',
});
@ -443,7 +431,7 @@ export default mixins(
} else if (key === 'help-about') {
this.aboutDialogVisible = true;
} else if (key === 'workflow-settings') {
this.workflowSettingsDialogVisible = true;
this.$store.dispatch('ui/openWorkflowSettingsModal');
} else if (key === 'workflow-new') {
const result = this.$store.getters.getStateIsDirty;
if(result) {
@ -477,7 +465,7 @@ export default mixins(
} else if (key === 'credentials-open') {
this.credentialOpenDialogVisible = true;
} else if (key === 'credentials-new') {
this.credentialNewDialogVisible = true;
this.$store.dispatch('ui/openCredentialsSelectModal');
} else if (key === 'execution-open-workflow') {
if (this.workflowExecution !== null) {
this.openWorkflow(this.workflowExecution.workflowId as string);
@ -491,6 +479,103 @@ export default mixins(
</script>
<style lang="scss">
.sidebar-popper{
.el-menu-item {
font-size: 0.9em;
height: 35px;
line-height: 35px;
color: $--custom-dialog-text-color;
--menu-item-hover-fill: #fff0ef;
.item-title {
position: absolute;
left: 55px;
}
.svg-inline--fa {
position: relative;
right: -3px;
}
}
}
#side-menu {
// Menu
.el-menu--vertical,
.el-menu {
border: none;
font-size: 14px;
--menu-item-hover-fill: #fff0ef;
.el-menu--collapse {
width: 75px;
}
.el-menu--popup,
.el-menu--inline {
font-size: 0.9em;
li.el-menu-item {
height: 35px;
line-height: 35px;
color: $--custom-dialog-text-color;
}
}
.el-menu-item,
.el-submenu__title {
color: $--color-primary;
font-size: 1.2em;
.el-submenu__icon-arrow {
color: $--color-primary;
font-weight: 800;
font-size: 1em;
}
.svg-inline--fa {
position: relative;
right: -3px;
}
.item-title {
position: absolute;
left: 73px;
}
.item-title-root {
position: absolute;
left: 60px;
top: 1px;
}
}
}
.el-menu-item {
a {
color: #666;
&.primary-item {
color: $--color-primary;
vertical-align: baseline;
}
}
&.logo-item {
background-color: $--color-primary !important;
height: $--header-height;
line-height: $--header-height;
* {
vertical-align: middle;
}
.icon {
position: relative;
height: 23px;
left: -10px;
top: -2px;
}
}
}
}
.about-icon {
margin-left: 5px;
}
@ -530,29 +615,6 @@ export default mixins(
transform: scale(1.1);
}
.el-menu-item {
a {
color: #666;
&.primary-item {
color: $--color-primary;
vertical-align: baseline;
}
}
&.logo-item {
background-color: $--color-primary !important;
height: $--header-height;
.icon {
position: relative;
height: 23px;
left: -10px;
top: -2px;
}
}
}
a.logo {
text-decoration: none;
}
@ -605,7 +667,7 @@ a.logo {
}
.el-menu-item.updates {
color: $--sidebar-inactive-color;
color: $--sidebar-inactive-color !important;
.item-title-root {
font-size: 13px;
top: 0 !important;

View file

@ -1,6 +1,6 @@
<template>
<div>
<el-menu-item
<n8n-menu-item
v-for="item in items"
:key="item.id"
:index="item.id"
@ -8,7 +8,7 @@
>
<font-awesome-icon :icon="item.properties.icon" />
<span slot="title" :class="{'item-title-root': root, 'item-title': !root}">{{ item.properties.title }}</span>
</el-menu-item>
</n8n-menu-item>
</div>
</template>
@ -41,4 +41,4 @@ export default Vue.extend({
},
},
});
</script>
</script>

View file

@ -1,57 +1,84 @@
<template>
<div>
<el-drawer
v-if="drawer"
:direction="drawerDirection"
:visible="visible && visibleDrawer"
:size="drawerWidth"
:before-close="closeDrawer"
>
<template v-slot:title>
<slot name="header" />
</template>
<template>
<slot name="content"/>
</template>
</el-drawer>
<el-dialog
v-else
:visible="dialogVisible"
:before-close="closeDialog"
:title="title"
:class="{ 'dialog-wrapper': true, [size]: true }"
:width="width"
append-to-body
>
<template v-slot:title>
<slot name="header" />
</template>
<div class="modal-content" @keydown.stop @keydown.enter="handleEnter" @keydown.esc="closeDialog">
<slot name="content"/>
<el-dialog
:visible="visible"
:before-close="closeDialog"
:title="title"
:class="{'dialog-wrapper': true, 'center': center, 'scrollable': scrollable}"
:width="width"
:show-close="showClose"
:custom-class="getCustomClass()"
:style="styles"
append-to-body
>
<template v-slot:title>
<slot name="header" v-if="!loading" />
</template>
<div class="modal-content" @keydown.stop @keydown.enter="handleEnter" @keydown.esc="closeDialog">
<slot v-if="!loading" name="content"/>
<div class="loader" v-else>
<n8n-spinner />
</div>
<el-row class="modal-footer">
<slot name="footer" :close="closeDialog" />
</el-row>
</el-dialog>
</div>
</div>
<el-row v-if="!loading" class="modal-footer">
<slot name="footer" :close="closeDialog" />
</el-row>
</el-dialog>
</template>
<script lang="ts">
import Vue from "vue";
const sizeMap: {[size: string]: string} = {
xl: '80%',
m: '50%',
default: '50%',
};
export default Vue.extend({
name: "Modal",
props: ['name', 'title', 'eventBus', 'size', 'drawer', 'drawerDirection', 'drawerWidth', 'visible'],
data() {
return {
visibleDrawer: this.drawer,
};
props: {
name: {
type: String,
},
title: {
type: String,
},
eventBus: {
type: Vue,
},
showClose: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
},
classic: {
type: Boolean,
},
beforeClose: {
type: Function,
},
customClass: {
type: String,
},
center: {
type: Boolean,
},
width: {
type: String,
default: '50%',
},
minWidth: {
type: String,
},
maxWidth: {
type: String,
},
height: {
type: String,
},
maxHeight: {
type: String,
},
scrollable: {
type: Boolean,
default: false,
},
},
mounted() {
window.addEventListener('keydown', this.onWindowKeydown);
@ -86,64 +113,99 @@ export default Vue.extend({
}
},
closeDialog(callback?: () => void) {
if (this.beforeClose) {
this.beforeClose(() => {
this.$store.commit('ui/closeTopModal');
if (typeof callback === 'function') {
callback();
}
});
return;
}
this.$store.commit('ui/closeTopModal');
if (typeof callback === 'function') {
callback();
}
},
closeDrawer() {
this.visibleDrawer = false;
setTimeout(() =>{
this.$store.commit('ui/closeTopModal');
this.visibleDrawer = true;
}, 300); // delayed for closing animation to take effect
getCustomClass() {
let classes = this.$props.customClass || '';
if (this.$props.classic) {
classes = `${classes} classic`;
}
return classes;
},
},
computed: {
width(): string {
return this.$props.size ? sizeMap[this.$props.size] : sizeMap.default;
},
isActive(): boolean {
return this.$store.getters['ui/isModalActive'](this.$props.name);
},
dialogVisible(): boolean {
visible(): boolean {
return this.$store.getters['ui/isModalOpen'](this.$props.name);
},
styles() {
const styles: {[prop: string]: string} = {};
if (this.height) {
styles['--dialog-height'] = this.height;
}
if (this.maxHeight) {
styles['--dialog-max-height'] = this.maxHeight;
}
if (this.maxWidth) {
styles['--dialog-max-width'] = this.maxWidth;
}
if (this.minWidth) {
styles['--dialog-min-width'] = this.minWidth;
}
return styles;
},
},
});
</script>
<style lang="scss">
.el-drawer__header {
margin: 0;
padding: 30px 30px 0 30px;
}
.el-drawer__body {
overflow: hidden;
}
.dialog-wrapper {
* {
box-sizing: border-box;
.el-dialog {
display: flex;
flex-direction: column;
max-width: var(--dialog-max-width, 80%);
min-width: var(--dialog-min-width, 420px);
height: var(--dialog-height);
max-height: var(--dialog-max-height);
}
&.xl > div, &.md > div {
min-width: 620px;
.el-dialog__body {
overflow: hidden;
display: flex;
flex-direction: column;
flex-grow: 1;
}
&.sm {
.modal-content {
overflow: hidden;
flex-grow: 1;
}
}
.scrollable .modal-content {
overflow-y: auto;
}
.center {
display: flex;
align-items: center;
justify-content: center;
> div {
max-width: 420px;
}
}
}
.modal-content > .el-row {
margin-bottom: 15px;
.loader {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary-tint-1);
font-size: 30px;
height: 80%;
}
</style>

View file

@ -0,0 +1,92 @@
<template>
<el-drawer
:direction="direction"
:visible="visible"
:size="width"
:before-close="close"
>
<template v-slot:title>
<slot name="header" />
</template>
<template>
<slot name="content"/>
</template>
</el-drawer>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
name: "ModalDrawer",
props: {
name: {
type: String,
},
eventBus: {
type: Vue,
},
direction: {
type: String,
},
width: {
type: String,
},
},
mounted() {
window.addEventListener('keydown', this.onWindowKeydown);
if (this.$props.eventBus) {
this.$props.eventBus.$on('close', () => {
this.close();
});
}
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
},
beforeDestroy() {
window.removeEventListener('keydown', this.onWindowKeydown);
},
methods: {
onWindowKeydown(event: KeyboardEvent) {
if (!this.isActive) {
return;
}
if (event && event.keyCode === 13) {
this.handleEnter();
}
},
handleEnter() {
if (this.isActive) {
this.$emit('enter');
}
},
close() {
this.$store.commit('ui/closeTopModal');
},
},
computed: {
isActive(): boolean {
return this.$store.getters['ui/isModalActive'](this.$props.name);
},
visible(): boolean {
return this.$store.getters['ui/isModalOpen'](this.$props.name);
},
},
});
</script>
<style lang="scss">
.el-drawer__header {
margin: 0;
padding: 30px 30px 0 30px;
}
.el-drawer__body {
overflow: hidden;
}
</style>

View file

@ -2,7 +2,13 @@
<div
v-if="isOpen(name) || keepAlive"
>
<slot :modalName="name" :active="isActive(name)" :open="isOpen(name)"></slot>
<slot
:modalName="name"
:active="isActive(name)"
:open="isOpen(name)"
:activeId="getActiveId(name)"
:mode="getMode(name)"
></slot>
</div>
</template>
@ -19,6 +25,12 @@ export default Vue.extend({
isOpen(name: string) {
return this.$store.getters['ui/isModalOpen'](name);
},
getMode(name: string) {
return this.$store.getters['ui/getModalMode'](name);
},
getActiveId(name: string) {
return this.$store.getters['ui/getModalActiveId'](name);
},
},
});
</script>

View file

@ -24,11 +24,36 @@
/>
</template>
</ModalRoot>
<ModalRoot :name="VERSIONS_MODAL_KEY" :keepAlive="true">
<template v-slot="{ modalName, open }">
<template v-slot="{ modalName }">
<UpdatesPanel
:modalName="modalName"
:visible="open"
/>
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_SETTINGS_MODAL_KEY">
<template v-slot="{ modalName }">
<WorkflowSettings
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_EDIT_MODAL_KEY">
<template v-slot="{ modalName, activeId, mode }">
<CredentialEdit
:modalName="modalName"
:mode="mode"
:activeId="activeId"
/>
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_SELECT_MODAL_KEY">
<template v-slot="{ modalName }">
<CredentialsSelectModal
:modalName="modalName"
/>
</template>
</ModalRoot>
@ -37,28 +62,37 @@
<script lang="ts">
import Vue from "vue";
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY } from '@/constants';
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import TagsManager from "@/components/TagsManager/TagsManager.vue";
import CredentialEdit from "./CredentialEdit/CredentialEdit.vue";
import DuplicateWorkflowDialog from "@/components/DuplicateWorkflowDialog.vue";
import WorkflowOpen from "@/components/WorkflowOpen.vue";
import ModalRoot from "./ModalRoot.vue";
import CredentialsSelectModal from "./CredentialsSelectModal.vue";
import UpdatesPanel from "./UpdatesPanel.vue";
import WorkflowSettings from "./WorkflowSettings.vue";
import TagsManager from "@/components/TagsManager/TagsManager.vue";
export default Vue.extend({
name: "Modals",
components: {
TagsManager,
CredentialEdit,
DuplicateWorkflowDialog,
WorkflowOpen,
ModalRoot,
CredentialsSelectModal,
UpdatesPanel,
WorkflowSettings,
TagsManager,
WorkflowOpen,
},
data: () => ({
DUPLICATE_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
WORKLOW_OPEN_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
VERSIONS_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
}),
});
</script>

View file

@ -4,7 +4,7 @@
<div class="parameter-name">
{{parameter.displayName}}:
<n8n-tooltip v-if="parameter.description" class="parameter-info" placement="top" >
<div slot="content" v-html="parameter.description"></div>
<div slot="content" v-html="addTargetBlank(parameter.description)"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</div>
@ -21,7 +21,7 @@
<collection-parameter :parameter="parameter" :values="value" :nodeValues="nodeValues" :path="getPath(index)" :hideDelete="hideDelete" @valueChanged="valueChanged" />
</div>
<div v-else>
<parameter-input class="duplicate-parameter-input-item" :parameter="parameter" :value="value" :displayOptions="true" :path="getPath(index)" @valueChanged="valueChanged" inputSize="small" />
<parameter-input class="duplicate-parameter-input-item" :parameter="parameter" :value="value" :displayOptions="true" :path="getPath(index)" @valueChanged="valueChanged" inputSize="small" :isReadOnly="isReadOnly" />
</div>
</div>
@ -75,6 +75,7 @@ export default mixins(genericHelpers)
},
},
methods: {
addTargetBlank,
addItem () {
const name = this.getPath();
let currentValue = get(this.nodeValues, name);
@ -92,7 +93,6 @@ export default mixins(genericHelpers)
this.$emit('valueChanged', parameterData);
},
addTargetBlank,
deleteItem (index: number) {
const parameterData = {
name: this.getPath(index),

View file

@ -37,7 +37,7 @@
</div>
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :shrink="true" :disabled="this.data.disabled"/>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :circle="true" :shrink="true" :disabled="this.data.disabled"/>
</div>
<div class="node-description">
<div class="node-name" :title="data.name">

View file

@ -88,7 +88,6 @@ export default mixins(externalHooks).extend({
filteredNodeTypes(): INodeCreateElement[] {
const nodeTypes: INodeCreateElement[] = this.searchItems;
const filter = this.searchFilter;
const returnData = nodeTypes.filter((el: INodeCreateElement) => {
const nodeType = (el.properties as INodeItemProps).nodeType;
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);

View file

@ -42,7 +42,19 @@ export default Vue.extend({
return this.allNodeTypes
.filter((nodeType: INodeTypeDescription) => {
return !HIDDEN_NODES.includes(nodeType.name);
});
}).reduce((accumulator: INodeTypeDescription[], currentValue: INodeTypeDescription) => {
// keep only latest version of the nodes
// accumulator starts as an empty array.
const exists = accumulator.findIndex(nodes => nodes.name === currentValue.name);
if (exists >= 0 && accumulator[exists].version < currentValue.version) {
// This must be a versioned node and we've found a newer version.
// Replace the previous one with this one.
accumulator[exists] = currentValue;
} else {
accumulator.push(currentValue);
}
return accumulator;
}, []);
},
categoriesWithNodes(): ICategoriesWithNodes {
return getCategoriesWithNodes(this.visibleNodeTypes);

View file

@ -1,27 +1,30 @@
<template>
<div v-if="credentialTypesNodeDescriptionDisplayed.length" class="node-credentials">
<credentials-edit :dialogVisible="credentialNewDialogVisible" :editCredentials="editCredentials" :setCredentialType="addType" :nodesInit="nodesInit" :node="node" @closeDialog="closeCredentialNewDialog" @credentialsCreated="credentialsCreated" @credentialsUpdated="credentialsUpdated"></credentials-edit>
<div class="headline">
Credentials
</div>
<div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name" class="credential-data">
<el-row v-if="displayCredentials(credentialTypeDescription)" class="credential-parameter-wrapper">
<el-row class="credential-parameter-wrapper">
<el-col :span="10" class="parameter-name">
{{credentialTypeNames[credentialTypeDescription.name]}}:
</el-col>
<el-col :span="12" class="parameter-value" :class="getIssues(credentialTypeDescription.name).length?'has-issues':''">
<div :style="credentialInputWrapperStyle(credentialTypeDescription.name)">
<n8n-select v-model="credentials[credentialTypeDescription.name]" :disabled="isReadOnly" @change="credentialSelected(credentialTypeDescription.name)" placeholder="Select Credential" size="small">
<n8n-select :value="selected[credentialTypeDescription.name]" :disabled="isReadOnly" @change="(value) => credentialSelected(credentialTypeDescription.name, value)" placeholder="Select Credential" size="small">
<n8n-option
v-for="(item, index) in credentialOptions[credentialTypeDescription.name]"
:key="item.name + '_' + index"
v-for="(item) in credentialOptions[credentialTypeDescription.name]"
:key="item.id"
:label="item.name"
:value="item.name">
</n8n-option>
<n8n-option
:key="NEW_CREDENTIALS_TEXT"
:value="NEW_CREDENTIALS_TEXT"
:label="NEW_CREDENTIALS_TEXT"
>
</n8n-option>
</n8n-select>
</div>
@ -34,7 +37,7 @@
</el-col>
<el-col :span="2" class="parameter-value credential-action">
<font-awesome-icon v-if="credentials[credentialTypeDescription.name]" icon="pen" @click="updateCredentials(credentialTypeDescription.name)" class="update-credentials clickable" title="Update Credentials" />
<font-awesome-icon v-if="selected[credentialTypeDescription.name] && isCredentialValid(credentialTypeDescription.name)" icon="pen" @click="editCredential(credentialTypeDescription.name)" class="update-credentials clickable" title="Update Credentials" />
</el-col>
</el-row>
@ -44,12 +47,8 @@
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import {
ICredentialsCreatedEvent,
ICredentialsResponse,
INodeUi,
INodeUpdatePropertiesInformation,
} from '@/Interface';
@ -59,15 +58,16 @@ import {
INodeTypeDescription,
} from 'n8n-workflow';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import ParameterInput from '@/components/ParameterInput.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { mapGetters } from "vuex";
import mixins from 'vue-typed-mixins';
const NEW_CREDENTIALS_TEXT = '- Create New -';
export default mixins(
genericHelpers,
nodeHelpers,
@ -78,11 +78,16 @@ export default mixins(
props: [
'node', // INodeUi
],
components: {
CredentialsEdit,
ParameterInput,
data () {
return {
NEW_CREDENTIALS_TEXT,
newCredentialUnsubscribe: null as null | (() => void),
};
},
computed: {
...mapGetters('credentials', {
credentialOptions: 'allCredentialsByType',
}),
credentialTypesNode (): string[] {
return this.credentialTypesNodeDescription
.map((credentialTypeDescription) => credentialTypeDescription.name);
@ -109,52 +114,16 @@ export default mixins(
} = {};
let credentialType: ICredentialType | null;
for (const credentialTypeName of this.credentialTypesNode) {
credentialType = this.$store.getters.credentialType(credentialTypeName);
credentialType = this.$store.getters['credentials/getCredentialTypeByName'](credentialTypeName);
returnData[credentialTypeName] = credentialType !== null ? credentialType.displayName : credentialTypeName;
}
return returnData;
},
},
data () {
return {
addType: undefined as string | undefined,
credentialNewDialogVisible: false,
credentialOptions: {} as { [key: string]: ICredentialsResponse[]; },
credentials: {} as {
[key: string]: string | undefined
},
editCredentials: null as object | null, // Credentials filter
newCredentialText: '- Create New -',
nodesInit: undefined as string[] | undefined,
};
},
watch: {
node () {
this.init();
selected(): {[type: string]: string} {
return this.node.credentials || {};
},
},
methods: {
closeCredentialNewDialog () {
this.credentialNewDialogVisible = false;
},
async credentialsCreated (eventData: ICredentialsCreatedEvent) {
await this.credentialsUpdated(eventData);
},
credentialsUpdated (eventData: ICredentialsCreatedEvent) {
if (!this.credentialTypesNode.includes(eventData.data.type)) {
return;
}
this.init();
Vue.set(this.credentials, eventData.data.type, eventData.data.name);
// Makes sure that it does also get set correctly on the node not just the UI
this.credentialSelected(eventData.data.type);
if (eventData.options.closeDialog === true) {
this.closeCredentialNewDialog();
}
},
credentialInputWrapperStyle (credentialType: string) {
let deductWidth = 0;
const styles = {
@ -170,29 +139,54 @@ export default mixins(
return styles;
},
credentialSelected (credentialType: string) {
const credential = this.credentials[credentialType];
if (credential === this.newCredentialText) {
// New credentials should be created
this.addType = credentialType;
this.editCredentials = null;
this.nodesInit = [ (this.node as INodeUi).type ];
this.credentialNewDialogVisible = true;
this.credentials[credentialType] = undefined;
listenForNewCredentials(credentialType: string) {
this.stopListeningForNewCredentials();
this.newCredentialUnsubscribe = this.$store.subscribe((mutation, state) => {
if (mutation.type === 'credentials/upsertCredential' || mutation.type === 'credentials/enableOAuthCredential'){
this.credentialSelected(credentialType, mutation.payload.name);
}
if (mutation.type === 'credentials/deleteCredential') {
this.credentialSelected(credentialType, mutation.payload.name);
this.stopListeningForNewCredentials();
}
});
},
stopListeningForNewCredentials() {
if (this.newCredentialUnsubscribe) {
this.newCredentialUnsubscribe();
}
},
credentialSelected (credentialType: string, credentialName: string) {
let selected = undefined;
if (credentialName === NEW_CREDENTIALS_TEXT) {
this.listenForNewCredentials(credentialType);
this.$store.dispatch('ui/openNewCredential', { type: credentialType });
}
else {
selected = credentialName;
}
const node = this.node as INodeUi;
const node: INodeUi = this.node;
const credentials = {
...(node.credentials || {}),
[credentialType]: selected,
};
const updateInformation: INodeUpdatePropertiesInformation = {
name: node.name,
name: this.node.name,
properties: {
credentials: JSON.parse(JSON.stringify(this.credentials)),
credentials,
},
};
this.$emit('credentialSelected', updateInformation);
},
displayCredentials (credentialTypeDescription: INodeCredentialDescription): boolean {
if (credentialTypeDescription.displayOptions === undefined) {
// If it is not defined no need to do a proper check
@ -200,6 +194,7 @@ export default mixins(
}
return this.displayParameter(this.node.parameters, credentialTypeDescription, '');
},
getIssues (credentialTypeName: string): string[] {
const node = this.node as INodeUi;
@ -213,56 +208,25 @@ export default mixins(
return node.issues.credentials[credentialTypeName];
},
updateCredentials (credentialType: string): void {
const name = this.credentials[credentialType];
const credentialData = this.credentialOptions[credentialType].find((optionData: ICredentialsResponse) => optionData.name === name);
if (credentialData === undefined) {
this.$showMessage({
title: 'Credentials not found',
message: `The credentials named "${name}" of type "${credentialType}" could not be found!`,
type: 'error',
});
return;
}
const editCredentials = {
id: credentialData.id,
name,
type: credentialType,
};
isCredentialValid(credentialType: string): boolean {
const name = this.node.credentials[credentialType];
const options = this.credentialOptions[credentialType];
this.editCredentials = editCredentials;
this.addType = credentialType;
this.credentialNewDialogVisible = true;
return options.find((option: ICredentialType) => option.name === name);
},
init () {
const node = this.node as INodeUi;
editCredential(credentialType: string): void {
const name = this.node.credentials[credentialType];
const options = this.credentialOptions[credentialType];
const selected = options.find((option: ICredentialType) => option.name === name);
this.$store.dispatch('ui/openExisitngCredential', { id: selected.id });
const newOption = {
name: this.newCredentialText,
};
let options = [];
// Get the available credentials for each type
for (const credentialType of this.credentialTypesNode) {
options = this.$store.getters.credentialsByType(credentialType);
options.push(newOption as ICredentialsResponse);
Vue.set(this.credentialOptions, credentialType, options);
}
// Set the current node credentials
if (node.credentials) {
Vue.set(this, 'credentials', JSON.parse(JSON.stringify(node.credentials)));
} else {
Vue.set(this, 'credentials', {});
}
this.listenForNewCredentials(credentialType);
},
},
mounted () {
this.init();
beforeDestroy () {
this.stopListeningForNewCredentials();
},
});
</script>
@ -317,6 +281,7 @@ export default mixins(
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text-base);
}
}

View file

@ -27,6 +27,7 @@ export default Vue.extend({
'size',
'shrink',
'disabled',
'circle',
],
computed: {
iconStyleData (): object {
@ -43,7 +44,7 @@ export default Vue.extend({
height: size + 'px',
'font-size': Math.floor(parseInt(this.size, 10) * 0.6) + 'px',
'line-height': size + 'px',
'border-radius': Math.ceil(size / 2) + 'px',
'border-radius': this.circle ? '50%': '4px',
};
},
isSvgIcon (): boolean {

View file

@ -80,7 +80,7 @@ export default mixins(
computed: {
nodeType (): INodeTypeDescription | null {
if (this.node) {
return this.$store.getters.nodeType(this.node.type);
return this.$store.getters.nodeType(this.node.type, this.node.typeVersion);
}
return null;
@ -511,24 +511,17 @@ export default mixins(
<style lang="scss">
.node-settings {
position: absolute;
left: 0;
width: 350px;
height: 100%;
border: none;
z-index: 200;
font-size: 0.8em;
color: #555;
border-radius: 2px 0 0 2px;
overflow: hidden;
min-width: 350px;
max-width: 350px;
font-size: var(--font-size-s);
.header-side-menu {
padding: 1em 0 1em 1.8em;
font-size: 1.35em;
font-size: var(--font-size-l);
background-color: $--custom-window-sidebar-top;
color: #555;
.node-info {
color: #555;
display: none;
padding-left: 0.5em;
font-size: 0.8em;
@ -546,18 +539,19 @@ export default mixins(
}
.node-parameters-wrapper {
height: calc(100% - 110px);
height: 100%;
font-size: .9em;
.el-tabs__header {
background-color: #fff5f2;
line-height: 2em;
}
.el-tabs {
height: 100%;
.el-tabs__content {
height: calc(100% - 17px);
overflow-y: auto;
height: 100%;
padding-bottom: 180px;
.el-tab-pane {
margin: 0 1em;

View file

@ -29,9 +29,11 @@
:rows="getArgument('rows')"
:value="displayValue"
:disabled="isReadOnly"
@input="onTextInputChange"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
:placeholder="isValueExpression?'':parameter.placeholder"
>
@ -48,6 +50,7 @@
:value="displayValue"
:disabled="isReadOnly"
@focus="setFocus"
@blur="onBlur"
@change="valueChanged"
:title="displayTitle"
:show-alpha="getArgument('showAlpha')"
@ -61,6 +64,7 @@
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
/>
</div>
@ -78,6 +82,7 @@
:picker-options="dateTimePickerOptions"
@change="valueChanged"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
/>
@ -92,7 +97,9 @@
:step="getArgument('numberStepSize')"
:disabled="isReadOnly"
@change="valueChanged"
@input="onTextInputChange"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
:title="displayTitle"
:placeholder="parameter.placeholder"
@ -107,9 +114,11 @@
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
:popper-append-to-body="true"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
>
<n8n-option
v-for="option in parameterOptions"
@ -136,6 +145,7 @@
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
>
<n8n-option v-for="option in parameterOptions" :value="option.value" :key="option.value" :label="option.name" >
@ -177,7 +187,6 @@
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</template>
@ -201,7 +210,6 @@ import ExpressionEdit from '@/components/ExpressionEdit.vue';
import PrismEditor from 'vue-prism-editor';
import TextEdit from '@/components/TextEdit.vue';
import { externalHooks } from '@/components/mixins/externalHooks';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
@ -210,7 +218,6 @@ import mixins from 'vue-typed-mixins';
export default mixins(
externalHooks,
genericHelpers,
nodeHelpers,
showMessage,
workflowHelpers,
@ -225,11 +232,14 @@ export default mixins(
},
props: [
'displayOptions', // boolean
'inputSize',
'isReadOnly',
'documentationUrl',
'parameter', // NodeProperties
'path', // string
'value',
'isCredential', // boolean
'inputSize',
'hideIssues', // boolean
'errorHighlight',
],
data () {
return {
@ -431,7 +441,7 @@ export default mixins(
return 'text';
},
getIssues (): string[] {
if (this.isCredential === true || this.node === null) {
if (this.hideIssues === true || this.node === null) {
return [];
}
@ -512,7 +522,7 @@ export default mixins(
if (this.isValueExpression) {
classes.push('expression');
}
if (this.getIssues.length) {
if (this.getIssues.length || this.errorHighlight) {
classes.push('has-issues');
}
return classes;
@ -561,7 +571,7 @@ export default mixins(
const resolvedNodeParameters = this.resolveParameter(currentNodeParameters) as INodeParameters;
try {
const options = await this.restApi().getNodeParameterOptions(this.node.type, this.path, this.remoteMethod, resolvedNodeParameters, this.node.credentials);
const options = await this.restApi().getNodeParameterOptions({name: this.node.type, version: this.node.typeVersion}, this.path, this.remoteMethod, resolvedNodeParameters, this.node.credentials);
this.remoteParameterOptions.push.apply(this.remoteParameterOptions, options);
} catch (error) {
this.remoteParameterOptionsLoadingIssues = error.message;
@ -602,8 +612,12 @@ export default mixins(
openExpressionEdit() {
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
return;
}
},
onBlur () {
this.$emit('blur');
},
setFocus () {
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
@ -644,6 +658,15 @@ export default mixins(
const [r, g, b, a] = valueMatch.splice(1, 4).map(v => Number(v));
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1) + ((1 << 8) + Math.floor((1-a)*255)).toString(16).slice(1);
},
onTextInputChange (value: string) {
const parameterData = {
node: this.node !== null ? this.node.name : this.nodeName,
name: this.path,
value,
};
this.$emit('textInput', parameterData);
},
valueChanged (value: string | number | boolean | Date | null) {
if (value instanceof Date) {
value = value.toISOString();
@ -810,6 +833,7 @@ export default mixins(
max-width: 340px;
margin: 6px 0;
white-space: normal;
padding-right: 20px;
.option-headline {
font-weight: var(--font-weight-bold);
@ -840,4 +864,16 @@ export default mixins(
align-items: center;
}
.errors {
margin-top: var(--spacing-2xs);
color: var(--color-danger);
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-regular);
a {
color: var(--color-danger);
text-decoration: underline;
}
}
</style>

View file

@ -0,0 +1,68 @@
<template>
<n8n-input-label
:label="parameter.displayName"
:tooltipText="parameter.description"
:required="parameter.required"
>
<parameter-input
:parameter="parameter"
:value="value"
:path="parameter.name"
:hideIssues="true"
:displayOptions="true"
:documentationUrl="documentationUrl"
:errorHighlight="showRequiredErrors"
@blur="onBlur"
@textInput="valueChanged"
@valueChanged="valueChanged"
inputSize="large"
/>
<div class="errors" v-if="showRequiredErrors">
This field is required. <a v-if="documentationUrl" :href="documentationUrl" target="_blank">Open docs</a>
</div>
</n8n-input-label>
</template>
<script lang="ts">
import { IUpdateInformation } from '@/Interface';
import ParameterInput from './ParameterInput.vue';
import Vue from 'vue';
export default Vue.extend({
name: 'ParameterInputExpanded',
components: {
ParameterInput,
},
props: {
parameter: {
},
value: {
},
showValidationWarnings: {
type: Boolean,
},
documentationUrl: {
type: String,
},
},
data() {
return {
blurred: false,
};
},
computed: {
showRequiredErrors(): boolean {
return this.$props.parameter.type !== 'boolean' && !this.value && this.$props.parameter.required && (this.blurred || this.showValidationWarnings);
},
},
methods: {
onBlur() {
this.blurred = true;
},
valueChanged(parameterData: IUpdateInformation) {
this.$emit('change', parameterData);
},
},
});
</script>

View file

@ -3,12 +3,12 @@
<el-col :span="isMultiLineParameter ? 24 : 10" class="parameter-name" :class="{'multi-line': isMultiLineParameter}">
<span class="title" :title="parameter.displayName">{{parameter.displayName}}</span>:
<n8n-tooltip class="parameter-info" placement="top" v-if="parameter.description" >
<div slot="content" v-html="parameter.description"></div>
<div slot="content" v-html="addTargetBlank(parameter.description)"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="isMultiLineParameter ? 24 : 14" class="parameter-value">
<parameter-input :parameter="parameter" :value="value" :displayOptions="displayOptions" :path="path" @valueChanged="valueChanged" inputSize="small" />
<parameter-input :parameter="parameter" :value="value" :displayOptions="displayOptions" :path="path" :isReadOnly="isReadOnly" @valueChanged="valueChanged" inputSize="small" />
</el-col>
</el-row>
</template>
@ -47,6 +47,7 @@ export default Vue
},
props: [
'displayOptions',
'isReadOnly',
'parameter',
'path',
'value',

View file

@ -31,7 +31,7 @@
</div>
{{parameter.displayName}}:
<n8n-tooltip placement="top" class="parameter-info" v-if="parameter.description" >
<div slot="content" v-html="parameter.description"></div>
<div slot="content" v-html="addTargetBlank(parameter.description)"></div>
<font-awesome-icon icon="question-circle"/>
</n8n-tooltip>
</div>
@ -70,6 +70,7 @@
:value="getParameterValue(nodeValues, parameter.name, path)"
:displayOptions="true"
:path="getPath(parameter.name)"
:isReadOnly="isReadOnly"
@valueChanged="valueChanged"
/>
</div>
@ -312,6 +313,7 @@ export default mixins(
margin: 0.3em 0;
padding: 0.8em;
line-height: 1.5;
word-break: normal;
a {
font-weight: var(--font-weight-bold);

View file

@ -26,10 +26,9 @@
<n8n-select size="mini" v-model="maxDisplayItems" @click.stop>
<n8n-option v-for="option in maxDisplayItemsOptions" :label="option" :value="option" :key="option" />
</n8n-select>
</span>&nbsp;/
</span>/
<strong>{{ dataCount }}</strong>
</div>
&nbsp;
<n8n-tooltip
v-if="runMetadata"
placement="right"
@ -41,7 +40,7 @@
<font-awesome-icon icon="info-circle" class="primary-color" />
</n8n-tooltip>
<span v-if="maxOutputIndex > 0">
| Output:&nbsp;
| Output:
</span>
<span class="opts" v-if="maxOutputIndex > 0" >
<n8n-select size="mini" v-model="outputIndex" @click.stop>
@ -51,7 +50,7 @@
</span>
<span v-if="maxRunIndex > 0">
| Data of Execution:&nbsp;
| Data of Execution:
</span>
<span class="opts">
<n8n-select v-if="maxRunIndex > 0" size="mini" v-model="runIndex" @click.stop>
@ -270,6 +269,9 @@ export default mixins(
MAX_DISPLAY_ITEMS_AUTO_ALL,
};
},
mounted() {
this.init();
},
computed: {
hasNodeRun(): boolean {
return Boolean(this.node && this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name));
@ -423,6 +425,18 @@ export default mixins(
},
},
methods: {
init() {
// Reset the selected output index every time another node gets selected
this.outputIndex = 0;
this.maxDisplayItems = 25;
this.refreshDataSize();
if (this.displayMode === 'Binary') {
this.closeBinaryDataDisplay();
if (this.binaryData.length === 0) {
this.displayMode = 'Table';
}
}
},
closeBinaryDataDisplay () {
this.binaryDataDisplayVisible = false;
this.binaryDataDisplayData = null;
@ -607,17 +621,8 @@ export default mixins(
},
},
watch: {
node (newNode, oldNode) {
// Reset the selected output index every time another node gets selected
this.outputIndex = 0;
this.maxDisplayItems = 25;
this.refreshDataSize();
if (this.displayMode === 'Binary') {
this.closeBinaryDataDisplay();
if (this.binaryData.length === 0) {
this.displayMode = 'Table';
}
}
node() {
this.init();
},
jsonData () {
this.refreshDataSize();
@ -630,8 +635,6 @@ export default mixins(
this.runIndex = Math.min(this.runIndex, this.maxRunIndex);
},
},
mounted () {
},
});
</script>
@ -639,14 +642,8 @@ export default mixins(
.run-data-view {
position: relative;
bottom: 0;
left: 0;
margin-left: 350px;
width: calc(100% - 350px);
width: 100%;
height: 100%;
z-index: 100;
color: #555;
font-size: 14px;
background-color: #f9f9f9;
.data-display-content {
@ -657,6 +654,7 @@ export default mixins(
right: 0;
overflow-y: auto;
line-height: 1.5;
word-break: normal;
.binary-data-row {
display: inline-flex;
@ -795,6 +793,10 @@ export default mixins(
.title-text {
display: inline-flex;
align-items: center;
> * {
margin-right: 2px;
}
}
.title-data-display-selector {

View file

@ -0,0 +1,58 @@
<template>
<span :class="$style.container">
<span :class="$style.saved" v-if="saved">{{ savedLabel }}</span>
<n8n-button
v-else
:label="isSaving ? savingLabel : saveLabel"
:loading="isSaving"
:disabled="disabled"
@click="$emit('click')"
/>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: "SaveButton",
props: {
saved: {
type: Boolean,
},
isSaving: {
type: Boolean,
},
disabled: {
type: Boolean,
},
saveLabel: {
type: String,
default: 'Save',
},
savingLabel: {
type: String,
default: 'Saving',
},
savedLabel: {
type: String,
default: 'Saved',
},
},
});
</script>
<style lang="scss" module>
.container {
width: 65px;
}
.saved {
color: $--custom-font-very-light;
font-size: 12px;
font-weight: 600;
line-height: 12px;
text-align: center;
padding: var(--spacing-2xs) var(--spacing-xs);
}
</style>

View file

@ -1,51 +0,0 @@
<template>
<span :class="$style.container">
<n8n-button v-if="isDirty || isNewWorkflow" label="Save" :disabled="isWorkflowSaving" @click="save" />
<span :class="$style.saved" v-else>Saved</span>
</span>
</template>
<script lang="ts">
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
export default mixins(workflowHelpers).extend({
name: "SaveWorkflowButton",
computed: {
...mapGetters({
isDirty: "getStateIsDirty",
}),
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},
isNewWorkflow(): boolean {
return !this.$route.params.name;
},
isSaved(): boolean {
return !this.isWorkflowSaving && !this.isDirty && !this.isNewWorkflow;
},
},
methods: {
save() {
this.saveCurrentWorkflow();
},
},
});
</script>
<style lang="scss" module>
.container {
width: 65px;
}
.saved {
color: $--custom-font-very-light;
font-size: 12px;
font-weight: 600;
line-height: 12px;
text-align: center;
padding: var(--spacing-2xs) var(--spacing-xs);
}
</style>

View file

@ -4,7 +4,7 @@
:name="modalName"
:eventBus="modalBus"
@enter="onEnter"
size="md"
minWidth="620px"
>
<template v-slot:content>
<el-row>
@ -186,5 +186,6 @@ export default mixins(showMessage).extend({
<style lang="scss" scoped>
.el-row {
min-height: $--tags-manager-min-height;
margin-bottom: 15px;
}
</style>

View file

@ -1,15 +1,59 @@
<template functional>
<span>
{{$options.format(props.date)}}
<span :title="$options.methods.convertToHumanReadableDate($props)">
{{$options.methods.format(props)}}
</span>
</template>
<script lang="ts">
import { format } from 'timeago.js';
import { format, LocaleFunc, register } from 'timeago.js';
import { convertToHumanReadableDate } from './helpers';
const localeFunc = (num: number, index: number, totalSec: number): [string, string] => {
// number: the timeago / timein number;
// index: the index of array below;
// totalSec: total seconds between date to be formatted and today's date;
return [
['Just now', 'Right now'],
['Just now', 'Right now'], // ['%s seconds ago', 'in %s seconds'],
['1 minute ago', 'in 1 minute'],
['%s minutes ago', 'in %s minutes'],
['1 hour ago', 'in 1 hour'],
['%s hours ago', 'in %s hours'],
['1 day ago', 'in 1 day'],
['%s days ago', 'in %s days'],
['1 week ago', 'in 1 week'],
['%s weeks ago', 'in %s weeks'],
['1 month ago', 'in 1 month'],
['%s months ago', 'in %s months'],
['1 year ago', 'in 1 year'],
['%s years ago', 'in %s years'],
][index] as [string, string];
};
register('main', localeFunc as LocaleFunc);
export default {
name: 'UpdatesPanel',
props: ['date'],
format,
props: {
date: {
type: String,
},
capitalize: {
type: Boolean,
default: false,
},
},
methods: {
format(props: {date: string, capitalize: boolean}) {
const text = format(props.date, 'main');
if (!props.capitalize) {
return text.toLowerCase();
}
return text;
},
convertToHumanReadableDate,
},
};
</script>
</script>

View file

@ -1,10 +1,8 @@
<template>
<Modal
<ModalDrawer
:name="modalName"
:drawer="true"
:visible="visible"
drawerDirection="ltr"
drawerWidth="520px"
direction="ltr"
width="520px"
>
<template slot="header">
<span :class="$style.title">Weve been busy </span>
@ -40,25 +38,25 @@
</div>
</section>
</template>
</Modal>
</ModalDrawer>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters } from 'vuex';
import Modal from './Modal.vue';
import ModalDrawer from './ModalDrawer.vue';
import TimeAgo from './TimeAgo.vue';
import VersionCard from './VersionCard.vue';
export default Vue.extend({
name: 'UpdatesPanel',
components: {
Modal,
ModalDrawer,
VersionCard,
TimeAgo,
},
props: ['modalName', 'visible'],
props: ['modalName'],
computed: {
...mapGetters('versions', ['nextVersions', 'currentVersion', 'infoUrl']),
},

View file

@ -1,7 +1,9 @@
<template>
<Modal
:name="modalName"
size="xl"
width="80%"
minWidth="620px"
:classic="true"
>
<template v-slot:header>
<div class="workflows-header">
@ -63,6 +65,7 @@ import Modal from '@/components/Modal.vue';
import TagsContainer from '@/components/TagsContainer.vue';
import TagsDropdown from '@/components/TagsDropdown.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { convertToDisplayDate } from './helpers';
export default mixins(
genericHelpers,
@ -176,8 +179,8 @@ export default mixins(
this.workflows = data;
this.workflows.forEach((workflowData: IWorkflowShortResponse) => {
workflowData.createdAt = this.convertToDisplayDate(workflowData.createdAt as number);
workflowData.updatedAt = this.convertToDisplayDate(workflowData.updatedAt as number);
workflowData.createdAt = convertToDisplayDate(workflowData.createdAt as number);
workflowData.updatedAt = convertToDisplayDate(workflowData.updatedAt as number);
});
this.isDataLoading = false;
},
@ -215,7 +218,6 @@ export default mixins(
flex-grow: 1;
h1 {
font-weight: 600;
line-height: 24px;
font-size: 18px;
}

View file

@ -1,7 +1,14 @@
<template>
<span>
<el-dialog class="workflow-settings" custom-class="classic" :visible="dialogVisible" append-to-body width="65%" title="Workflow Settings" :before-close="closeDialog">
<div v-loading="isLoading">
<Modal
:name="modalName"
width="65%"
maxHeight="80%"
title="Workflow Settings"
:eventBus="modalBus"
:scrollable="true"
>
<template v-slot:content>
<div v-loading="isLoading" class="workflow-settings">
<el-row>
<el-col :span="10" class="setting-name">
Error Workflow:
@ -156,12 +163,14 @@
</el-col>
</el-row>
</div>
<div class="action-buttons">
<n8n-button label="Save" size="large" @click="saveSettings" />
</div>
</div>
</el-dialog>
</span>
</template>
<template v-slot:footer>
<div class="action-buttons">
<n8n-button label="Save" size="large" @click="saveSettings" />
</div>
</template>
</Modal>
</template>
<script lang="ts">
@ -177,6 +186,7 @@ import {
IWorkflowSettings,
IWorkflowShortResponse,
} from '@/Interface';
import Modal from './Modal.vue';
import mixins from 'vue-typed-mixins';
@ -187,9 +197,14 @@ export default mixins(
showMessage,
).extend({
name: 'WorkflowSettings',
props: [
'dialogVisible',
],
props: {
modalName: {
type: String,
},
},
components: {
Modal,
},
data () {
return {
isLoading: true,
@ -220,22 +235,74 @@ export default mixins(
executionTimeout: this.$store.getters.executionTimeout,
maxExecutionTimeout: this.$store.getters.maxExecutionTimeout,
timeoutHMS: { hours: 0, minutes: 0, seconds: 0 } as ITimeoutHMS,
modalBus: new Vue(),
};
},
watch: {
dialogVisible (newValue, oldValue) {
if (newValue) {
this.openDialog();
}
this.$externalHooks().run('workflowSettings.dialogVisibleChanged', { dialogVisible: newValue });
},
async mounted () {
if (this.$route.params.name === undefined) {
this.$showMessage({
title: 'No workflow active',
message: `No workflow active to display settings of.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
this.defaultValues.saveDataErrorExecution = this.$store.getters.saveDataErrorExecution;
this.defaultValues.saveDataSuccessExecution = this.$store.getters.saveDataSuccessExecution;
this.defaultValues.saveManualExecutions = this.$store.getters.saveManualExecutions;
this.defaultValues.timezone = this.$store.getters.timezone;
this.isLoading = true;
const promises = [];
promises.push(this.loadWorkflows());
promises.push(this.loadSaveDataErrorExecutionOptions());
promises.push(this.loadSaveDataSuccessExecutionOptions());
promises.push(this.loadSaveExecutionProgressOptions());
promises.push(this.loadSaveManualOptions());
promises.push(this.loadTimezones());
try {
await Promise.all(promises);
} catch (error) {
this.$showError(error, 'Problem loading settings', 'The following error occurred loading the data:');
}
const workflowSettings = JSON.parse(JSON.stringify(this.$store.getters.workflowSettings));
if (workflowSettings.timezone === undefined) {
workflowSettings.timezone = 'DEFAULT';
}
if (workflowSettings.saveDataErrorExecution === undefined) {
workflowSettings.saveDataErrorExecution = 'DEFAULT';
}
if (workflowSettings.saveDataSuccessExecution === undefined) {
workflowSettings.saveDataSuccessExecution = 'DEFAULT';
}
if (workflowSettings.saveExecutionProgress === undefined) {
workflowSettings.saveExecutionProgress = 'DEFAULT';
}
if (workflowSettings.saveManualExecutions === undefined) {
workflowSettings.saveManualExecutions = 'DEFAULT';
}
if (workflowSettings.executionTimeout === undefined) {
workflowSettings.executionTimeout = this.$store.getters.executionTimeout;
}
if (workflowSettings.maxExecutionTimeout === undefined) {
workflowSettings.maxExecutionTimeout = this.$store.getters.maxExecutionTimeout;
}
Vue.set(this, 'workflowSettings', workflowSettings);
this.timeoutHMS = this.convertToHMS(workflowSettings.executionTimeout);
this.isLoading = false;
this.$externalHooks().run('workflowSettings.dialogVisibleChanged', { dialogVisible: true });
},
methods: {
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
this.modalBus.$emit('close');
},
setTimeout (key: string, value: string) {
const time = value ? parseInt(value, 10) : 0;
@ -362,66 +429,6 @@ export default mixins(
Vue.set(this, 'workflows', workflows);
},
async openDialog () {
if (this.$route.params.name === undefined) {
this.$showMessage({
title: 'No workflow active',
message: `No workflow active to display settings of.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
this.defaultValues.saveDataErrorExecution = this.$store.getters.saveDataErrorExecution;
this.defaultValues.saveDataSuccessExecution = this.$store.getters.saveDataSuccessExecution;
this.defaultValues.saveManualExecutions = this.$store.getters.saveManualExecutions;
this.defaultValues.timezone = this.$store.getters.timezone;
this.isLoading = true;
const promises = [];
promises.push(this.loadWorkflows());
promises.push(this.loadSaveDataErrorExecutionOptions());
promises.push(this.loadSaveDataSuccessExecutionOptions());
promises.push(this.loadSaveExecutionProgressOptions());
promises.push(this.loadSaveManualOptions());
promises.push(this.loadTimezones());
try {
await Promise.all(promises);
} catch (error) {
this.$showError(error, 'Problem loading settings', 'The following error occurred loading the data:');
}
const workflowSettings = JSON.parse(JSON.stringify(this.$store.getters.workflowSettings));
if (workflowSettings.timezone === undefined) {
workflowSettings.timezone = 'DEFAULT';
}
if (workflowSettings.saveDataErrorExecution === undefined) {
workflowSettings.saveDataErrorExecution = 'DEFAULT';
}
if (workflowSettings.saveDataSuccessExecution === undefined) {
workflowSettings.saveDataSuccessExecution = 'DEFAULT';
}
if (workflowSettings.saveExecutionProgress === undefined) {
workflowSettings.saveExecutionProgress = 'DEFAULT';
}
if (workflowSettings.saveManualExecutions === undefined) {
workflowSettings.saveManualExecutions = 'DEFAULT';
}
if (workflowSettings.executionTimeout === undefined) {
workflowSettings.executionTimeout = this.$store.getters.executionTimeout;
}
if (workflowSettings.maxExecutionTimeout === undefined) {
workflowSettings.maxExecutionTimeout = this.$store.getters.maxExecutionTimeout;
}
Vue.set(this, 'workflowSettings', workflowSettings);
this.timeoutHMS = this.convertToHMS(workflowSettings.executionTimeout);
this.isLoading = false;
},
async saveSettings () {
// Set that the active state should be changed
const data: IWorkflowDataUpdate = {

View file

@ -1,5 +1,21 @@
import dateformat from 'dateformat';
const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
export function addTargetBlank(html: string) {
return html.includes('href=')
? html.replace(/href=/g, 'target="_blank" href=')
: html;
}
export function convertToDisplayDate (epochTime: number) {
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
}
export function convertToHumanReadableDate (epochTime: number) {
return dateformat(epochTime, 'd mmmm, yyyy @ HH:MM Z');
}
export function getAppNameFromCredType(name: string) {
return name.split(' ').filter((word) => !KEYWORDS_TO_FILTER.includes(word)).join(' ');
}

View file

@ -1,7 +1,4 @@
import dateformat from 'dateformat';
import { showMessage } from '@/components/mixins/showMessage';
import { MessageType } from '@/Interface';
import { debounce } from 'lodash';
import mixins from 'vue-typed-mixins';
@ -22,9 +19,6 @@ export const genericHelpers = mixins(showMessage).extend({
},
},
methods: {
convertToDisplayDate (epochTime: number) {
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
},
displayTimer (msPassed: number, showMs = false): string {
if (msPassed < 60000) {
if (showMs === false) {
@ -91,20 +85,5 @@ export const genericHelpers = mixins(showMessage).extend({
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);
},
async confirmMessage (message: string, headline: string, type = 'warning' as MessageType, confirmButtonText = 'OK', cancelButtonText = 'Cancel'): Promise<boolean> {
try {
await this.$confirm(message, headline, {
confirmButtonText,
cancelButtonText,
type,
dangerouslyUseHTMLString: true,
});
return true;
} catch (e) {
return false;
}
},
},
});

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